ВУЗ: Не указан
Категория: Не указан
Дисциплина: Не указана
Добавлен: 30.10.2023
Просмотров: 413
Скачиваний: 1
ВНИМАНИЕ! Если данный файл нарушает Ваши авторские права, то обязательно сообщите нам.
СОДЕРЖАНИЕ
1 ... 15 16 17 18 19 20 21 22 ... 34
158 Глава 5
связным списком, это исходный головной указатель, имеющий зна- чение NULL. Здесь это определенно вызовет проблему, поскольку мы не проверяем это и код вызовет сбой, если мы попытаемся разыме- новать указатель loopPtr после входа в цикл. Кроме того, мы должны рассмотреть возможность, что идентификатор, переданный клиент- ским кодом, вообще не соответствует никаким записям в коллекции.
В этом случае, даже если _listHead не NULL, loopPtr в конце концов станет NULL, когда мы достигнем конца списка.
Таким образом, основная идея состоит в том, что мы должны остановить цикл, если значение loopPtr становится NULL. Это не сложно, но что мы вернем в такой ситуации? Очевидно, мы не можем вернуть loopPtr->studentData, поскольку loopPtr будет NULL. Вме- сто этого мы можем создать и вернуть фиктивную запись student-
Record с очевидно некорректными значениями. studentRecord studentCollection::recordWithNumber(int idNum) {
studentNode * loopPtr = _listHead;
while (
XloopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
loopPtr = loopPtr->next;
}
if (
YloopPtr == NULL) {
Z studentRecord dummyRecord( –1, –1, "");
return dummyRecord;
} else {
return loopPtr->studentData;
}
}
В этой версии метода, если цикл закончен, а указатель цикла со- держит значение NULL
Y
, мы создаем фиктивную запись с пустой строкой вместо фамилии студента и значениями –1 для его оценки и идентификатора
Z
и возвращаем эту запись. Возвращаясь к циклу, мы проверяем, равен ли NULL указатель loopPtr, что может произойти, если список пуст либо мы безуспешно полностью его просмотрели.
Обратим внимание, что выражение условия цикла
X
— составное вы- ражение с условием loopPtr != NULL, идущим первым. Именно так и должно быть. Язык C++ использует для оценки составных логических выражений механизм, известный как сокращенные вычисления . Проще говоря, правая часть составного логического выражения не рассма- тривается, если значение всего выражения уже известно. Поскольку
&& означает логическое и, то если левая часть выражения && оказыва- ется ложной, все выражение также оказывается ложью, независимо от значения правой части. Для повышения эффективности, C++ ис- пользует этот факт, опуская расчет правой части выражения && , если левая часть оказывается ложной (для || или логического или правая часть по той же причине не рассчитывается, когда левая часть истин- на). Следовательно, когда loopPtr равен NULL, выражение loopPtr !=
NULL
ложно, и правая часть выражения && не рассчитывается. Без со- кращенных вычислений правая часть рассчитывалась бы и указатель на NULL разыменовывался бы, вызывая сбой программы.
Решение задач с классами 159
Эта реализация позволяет избежать потенциальной поломки, ха- рактерной для первой версии, однако она оказывает слишком много доверия клиентскому коду. То есть функция, вызывающая этот метод, должна проверить вернувшуюся обратно запись studentRecord и до выполнения дальнейших операций убедиться, что она не фиктивная.
Если вы похожи на меня, то вам должно быть немного не по себе .
.%4B/
Есть еще один способ. C++, как и многие другие языки программирования, пред-
лагает механизм, известный как исключения, позволяющий методу или глобальной
функции недвусмысленно сообщить об ошибке туда, откуда она была вызвана. Это
сделано для ситуации, которая сложилась в этом методе, когда нет хорошего отве-
та на вопрос, что возвращать, если ввод был некорректным. Исключения сложнее,
чем мы можем позволить себе рассматривать сейчас, и, к сожалению, реализация
исключений в C++ не решает проблему доверия, описанную в предыдущем абзаце.
Перегруппировка списка
Метод removeRecord похож на recordWithNumber в той, что мы должны пройти список для нахождения узла, который необходимо удалить.
Однако есть задачи сверх этого. Удаление узла из списка требует вни- мательности, поскольку остальные узлы должны остаться связным списком. Проще всего зашить сделанную нами дырочку соединени- ем узла, который шел перед удаленным, с тем, который шел следом за ним. Нам не нужно набрасывать функцию, потому что у нас уже есть прототип функции в объявлении класса, поэтому перей дем к тестам.
studentCollection s;
studentRecord stu3(84, 1152, "Sue");
studentRecord stu2(75, 4875, "Ed");
studentRecord stu1(98, 2938, "Todd");
s.addRecord(stu3);
s.addRecord(stu2);
s.addRecord(stu1);
X s.removeRecord(4875)
Мы создали объект s класса studentCollection, а также три объ- екта класса studentRecord, каждый из которых был добавлен в кол- лекцию. Обратите внимание, что мы могли бы использовать одну и ту же запись, меняя значения между вызовами addRecord, но не стали этого делать для упрощения тестового кода. Последняя строка в те- сте вызывает метод removeRecord
X
, который в данном случае удалит вторую запись, то есть запись для студента по имени «Ed». Используя тот же стиль схем указателей, что и в главе 4, проиллюстрируем на рис. 5.1 состояние памяти «до» и «после» этого вызова.
На рис. 5.1 (а) мы видим связный список, созданный нашим тесто- вым кодом. Обратите внимание, что, поскольку мы используем класс, наши соглашения о схемах слегка искажены. В левой части рисунка
160 Глава 5
изображен стек/куча, в котором находятся _listHead, являющийся приватным полем внутри объекта s класса studentCollection, и idNum, являющийся параметром метода removeRecord. В правой части рисун- ка изображен сам список в куче. Помните, что addRecord размещает новую запись в начало списка, поэтому записи расположены в обрат- ном порядке по сравнению с тем, как они добавлялись в тестовом коде. Идентификатор среднего узла, «Ed» совпадает с параметром
4875, поэтому он должен быть удален из списка. Рис. 5.1 (б) показыва- ет результат вызова метода. Первый узел списка, «Todd», теперь ука- зывает на узел, который ранее был в списке третьим, то есть на «Sue».
Узел «Ed» больше не связан с большим списком и удален.
Рис. 5.1. Ситуация «до» и «после» вызова метода
removeRecord в тестовом примере
Теперь, зная, какого результата мы хотим добиться, начнем пи- сать код. Поскольку мы знаем, что нам надо найти узел с соответ- ствующим идентификатором, мы могли бы начать с цикла while из метода recordWithNumber. По окончанию это цикла указатель будет указывать на узел, который надо удалить. К сожалению, нам надо кое-что еще, чтобы закончить удаление. Посмотрите на рис. 5.1. Что- бы закрыть дыру и восстановить связный список, мы должны изме- нить поле next узла «Todd». Если все, что у нас есть, это указатель на узел «Ed», способа указать на «Todd» не существует, потому что каждый узел в связном списке указывает на своего последователя, а не предшественника. (Из-за подобной ситуации некоторые связные списки связывают в обоих направлениях, такие списки называются
Решение задач с классами 161
двойные связные списки , но нужда в них возникает редко.) Таким об- разом, в дополнение к указателю на удаляемый узел (который назы- вается loopPtr, если мы применим код предыдущей функции), нам необходим указатель на предыдущий узел: давайте назовем такой указатель trailing. Рис. 5.2 демонстрирует эту концепции примени- мо к нашему примеру.
Рис. 5.2. Указатели, необходимые для удаления узла с номером
idNum
C указателями loopPtr ссылающимся на удаляемый узел и trail- ing
, ссылающимся на предыдущий узел, мы можем удалить желае- мый узел и сохранить список.
void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
X studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
Ytrailing = loopPtr;
loopPtr = loopPtr->next;
}
Z if (loopPtr == NULL) return;
[ trailing->next = loopPtr->next;
\ delete loopPtr;
}
Первая часть этой функции похожа на метод recordWithNumber, с тем исключением, что мы объявили указатель trailing
X
и внутри цикла присвоили ему старое значение loopPtr
Y
до перехода loopPtr к следующему узлу. Таким образом, указатель trailing всегда нахо- дится на один узел позади loopPtr. Благодаря нашей работе с пре- дыдущей функцией мы уже знаем про специальный случай. Поэтому, когда цикл закончится, мы проверяем, не равен ли loopPtr значе- нию NULL. Если равен, это означает, что мы не нашли узел с заданным идентификатором и немедленно возвращаемся
Z
. Я называю выра- жение return в середине функции «смываться». Некоторые програм- мисты возражают против этого, потому что функцию с несколькими точками выхода тяжело читать. Однако альтернативный вариант в этом случае еще один уровень вложения для инструкции if, которое идет следом, так что я лучше смоюсь.
Определив, что удаляемый узел существует, пора удалить его. На диаграмме видео, что необходимо установить, чтобы поле next узла
162 Глава 5
trailing указывало на узел, на который в настоящий момент указы- вает поле next узла loopPtr
[
. Затем мы можем безопасно удалить узел, на который указывает loopPtr
\
Этот код работает для нашего тестового примера, но, как всег- да, необходимо проверить его на возможных специальных случаях .
Мы уже разобрались с возможностью, что номер idNum отсутствует в записях нашей коллекции, но нет ли еще какой-нибудь проблемы?
Посмотрим на пример. Изменится ли что-нибудь, если мы попробу- ем удалить первый или третий узел вместо среднего? Тестирование показывает отсутствие проблем при удалении третьего (последнего) узла. Однако удаление первого узла и в самом деле вызывает труд- ности из-за того, что в этом случае отсутствует предыдущий узел, на который должен указывать trailing. Для этого мы должны мани- пулировать указателем _listHead. Рис. 5.3 иллюстрирует ситуацию, сложившуюся по окончании цикла while.
Рис. 5.3. Положение до удаления первого узла в списке
В этой ситуации нам нужно перенаправить _listHead на бывший второй узел в списке, то есть на узел «Ed». Давайте перепишем наш метод, чтобы решить данную проблему. void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
trailing = loopPtr;
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) return;
X if (trailing == NULL) {
Y _listHead = _listHead–>next;
} else {
trailing->next = loopPtr->next;
}
delete loopPtr;
}
Как видите, как проверка условия
X
, так и код, решающий про- блему
Y
, просты, поскольку мы тщательно проанализировали ситуа- цию, прежде чем писать его.
Решение задач с классами 163
Деструктор
Реализовав эти три метода, мы можем подумать, что класс stu- dentCollection готов. Однако это не так. Первое, чего не хватает нашему классу, это деструктор. Это специальный метод, вызывае- мый, когда объект выходит из области видимости (когда функция, объявившая этот объект, закончила работу). Если в классе отсут- ствуют динамические данные, обычно деструктор не нужен. Одна- ко если такие данные присутствуют, без деструктора не обойтись.
Помните, для того, чтобы не допустить утечек памяти, мы долж- ны применить ключевое слово delete для всех объектов, которым была выделена память с помощью ключевого слова new. Если объ- ект нашего класса studentCollection содержит три узла, необхо- димо освободить память, которую занимает каждый из них. К сча- стью, это несложно. Нам необходимо пройти по нашему связному списку, удаляя узлы. Однако вместо того, чтобы делать это непо- средственно, давайте напишем служебный метод, удаляющий все узлы в studentList. В приватной секции нашего класса добавим объявление:
void deleteList(studentList &listPtr);
Код для этого метода будет выглядеть так:
void studentCollection::deleteList(studentList &listPtr) {
while (listPtr != NULL) {
X studentNode * temp = listPtr;
Y listPtr = listPtr->next;
Z delete temp;
}
}
Во время обхода указатель на текущий узел копируется во вре- менную переменную
X
, передвигается к следующему узлу
Y
, а за- тем удаляется узел, на который указывает временная переменная
Z
С помощью этого кода мы можем написать деструктор очень просто.
Прежде всего, добавим деструктор в публичную секцию объявления нашего класса:
studentCollection();
Обратите внимание, что, как и у конструктора, имя деструктора совпадает с названием класса и в нем отсутствует возвращаемый тип.
Тильда перед именем позволяет отличить деструктор от конструкто- ра. Реализация будет выглядеть так:
studentCollection::studentCollection() {
deleteList(_listHead);
}
Код в этом методе прост, однако очень важно протестировать де- структор. Хотя плохо написанный деструктор и может вызвать сбой вашей программы, большинство проблем с деструктором вызывают
164 Глава 5
не ошибку программы, но утечки памяти или, что хуже, необъясни- мое поведение программы. Следовательно, важно протестировать деструктор с помощью отладчика среды разработки, так чтобы вы могли видеть, действительно ли деструктор вызывает delete для каждого узла.
Глубокое копирование
Осталась еще одна серьезная проблема. В главе 4 мы кратко обсу- дили концепцию перекрестных связей , когда две переменных типа указатель имеют одно и то же значение. Даже если это разные пе- ременные, они указывают на одну и ту же структуру данных; следо- вательно, изменение структуры одной переменной изменяет обе.
Эта проблема часто всплывает в классах, включающих динамиче- ски распределенную память. Чтобы увидеть, почему это проблема, рассмотрим следующий элементарный код на C++:
int x = 10;
int y = 15;
x = y;
X x = 5;
Предположим, я спросил бы вас, какое влияние последнее выра- жение
X
оказывает на переменную y. Вы, вероятно, удивились бы не оговорился ли я. Последнее выражение не оказывает совсем никако- го эффекта на y, только на x. А теперь посмотрите на это:
studentCollection s1;
studentCollection s2;
studentRecord r1(85, 99837, "John");
s2.addRecord(r1);
studentRecord r2(77, 4765, "Elsie");
s2.addRecord(r2);
X s1 = s2;
Y s2.removeRecord(99837)
Предположим, я спрошу вас, какое влияние окажет последнее вы- ражение
Y
на s1. К сожалению, влияние действительно будет. Хотя s1
и s2 — два разных объекта, они отныне не полностью отдельные объекты. По умолчанию, когда один объект приравнен к другому, как мы сейчас приравняли объекты s1 и s2
X
, язык C++ производит то, что известно как поверхностное копирование . При поверхностном ко- пировании каждое поле класса одного объекта присваивается друго- му. Так если, _listHead — наше единственное поле класса, публично, s2 = s1 будет аналогично s1._listHead = s2._listHead. Получае- тся, что поле _listHead обоих объектов указывает на одно и то же место в памяти: узел для «Elsie», который указывает на другой узел, а именно на узел «John». Поэтому после удаления узла «John», он уда- ляется из двух списков, поскольку на самом деле существует только один список. Рис. 5.4 иллюстрирует положение, сложившееся к кон- цу данного кода.
Решение задач с классами 165
Рис. 5.4. Результаты поверхностного копирования при перекрестных связях; удаление узла
«John» из одного списка удаляет из обоих
Однако все может быть еще хуже. Что, если последняя строка кода удалит первую запись — узел «Elsie»? В этом случае указатель _
listHead объекта s2 станет указывать на узел «John», а узел «Elsie» будет удален. Однако указатель _listHead объекта s1 по-прежнему будет указывать на удаленный узел «Elsie», то есть возникнет опасная висячая ссылка , как показано на рис. 5.5.
Рис. 5.5. Удаление из s2 приводит к висящей ссылке в s1
Для решения этой проблемы применяют глубокое копирование, то есть не только копирование указателя на структуру, но копирова- ние всей структуры. В нашем случае это означает копирование всех узлов списка. Тем самым происходит истинное копирование. Как и ранее, давайте начнем с приватных служебных методов, то есть в нашем случае с метода, копирующего studentList. Объявление в приватной секции класса выглядит примерно так:
studentList copiedList(const studentList original);
Как и ранее, я выбрал существительное для названия метода, возвра щающего значение. Реализация метода выглядит так:
X studentCollection::studentList studentCollection::copiedList(const studentList original) {
Y if (original == NULL) {
return NULL;
}
studentList newList = new studentNode;
Z newList->studentData = original->studentData;
[ studentNode * oldLoopPtr = original->next;
\ studentNode * newLoopPtr = newList;
while (oldLoopPtr != NULL) {
166 Глава 5
] newLoopPtr->next = new studentNode;
newLoopPtr = newLoopPtr->next;
newLoopPtr->studentData = oldLoopPtr->studentData;
oldLoopPtr = oldLoopPtr->next;
}
^ newLoopPtr->next = NULL;
_ return newList;
}
В этом методе происходит много всего, поэтому давайте будем раз- бираться постепенно. Прежде всего, обратим внимание, что, опреде- ляя тип возвращаемого значения, мы должны указать в префиксе имя класса
X
. В противном случае компилятор не поймет, о каком типе мы говорим. (Внутри метода это необязательно, поскольку компилятор уже знает, частью какого класса является метод – немного запутанно!)
Мы проверяем, не пуст ли входящий список. Если он пуст, смываем- ся
Y
. Теперь, зная, что у нас есть список для копирования, копируем данные первого узла до начала цикла
Z
, поскольку для этого узла нам необходимо изменить указатель на начало нового списка.
Затем мы устанавливаем два указателя для прохода по списку. Ука- затель oldLoopPtr
[
проходит через пришедший список; он всегда бу- дет указывать на узел, который мы собираемся копировать. Указатель newLoopPtr
\
проходит через новый, скопированный, список и всегда указывает на последний созданный узел, то есть узел, до которого мы будем добавлять следующий узел. Как и в методе removeRecord нам ну- жен здесь хвостовой указатель. В цикле
]
мы создаем новый узел, пе- редвигаем newLoopPtr, чтобы он указывал на него, копируем данные из старого узла в новый и передвигаем указатель oldLoopPtr. После цикла мы завершаем новый список, присваивая значение NULL полю next по- следнего узла
^
и возвращаем (return) указатель на новый список
_
Как же этот служебный метод решает обозначенную выше пробле- му? Сам по себе никак. Но, добавив этот код, мы можем перегрузить оператор присваивания. Перегрузка операторов — особенность языка
C++, позволяющая изменять действия, производимые встроенными операторами над определенными типами данных. В нашем случае мы хотим перегрузить оператор присваивания (=) так, чтобы вместо уста- новленного по умолчанию поверхностного копирования, он вызывал наш метод copiedList для выполнения глубокого копирования. В пуб- личной секции класса добавим следующее:
XstudentCollection& Yoperator=(Zconst studentCollection & [rhs);
Перегружаемый оператор определяется названием метода с ис- пользованием ключевого слова operator, за которым идет оператор, который мы хотим перегрузить
Y
. Имя выбранное мной для параме- тра (rhs
[
) обычный выбор для перегрузки операторов, поскольку это сокращение от словосочетания «правая сторона» (right-hand side).
Это помогает программистам упрощать себе жизнь. Так, в присвое- нии, о котором идет речь s2 = s1, объект s1 будет правой стороной оператора присваивания, а s2 — левой стороной . На правую часть мы
Решение задач с классами
1 ... 16 17 18 19 20 21 22 23 ... 34
связным списком, это исходный головной указатель, имеющий зна- чение NULL. Здесь это определенно вызовет проблему, поскольку мы не проверяем это и код вызовет сбой, если мы попытаемся разыме- новать указатель loopPtr после входа в цикл. Кроме того, мы должны рассмотреть возможность, что идентификатор, переданный клиент- ским кодом, вообще не соответствует никаким записям в коллекции.
В этом случае, даже если _listHead не NULL, loopPtr в конце концов станет NULL, когда мы достигнем конца списка.
Таким образом, основная идея состоит в том, что мы должны остановить цикл, если значение loopPtr становится NULL. Это не сложно, но что мы вернем в такой ситуации? Очевидно, мы не можем вернуть loopPtr->studentData, поскольку loopPtr будет NULL. Вме- сто этого мы можем создать и вернуть фиктивную запись student-
Record с очевидно некорректными значениями. studentRecord studentCollection::recordWithNumber(int idNum) {
studentNode * loopPtr = _listHead;
while (
XloopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
loopPtr = loopPtr->next;
}
if (
YloopPtr == NULL) {
Z studentRecord dummyRecord( –1, –1, "");
return dummyRecord;
} else {
return loopPtr->studentData;
}
}
В этой версии метода, если цикл закончен, а указатель цикла со- держит значение NULL
Y
, мы создаем фиктивную запись с пустой строкой вместо фамилии студента и значениями –1 для его оценки и идентификатора
Z
и возвращаем эту запись. Возвращаясь к циклу, мы проверяем, равен ли NULL указатель loopPtr, что может произойти, если список пуст либо мы безуспешно полностью его просмотрели.
Обратим внимание, что выражение условия цикла
X
— составное вы- ражение с условием loopPtr != NULL, идущим первым. Именно так и должно быть. Язык C++ использует для оценки составных логических выражений механизм, известный как сокращенные вычисления . Проще говоря, правая часть составного логического выражения не рассма- тривается, если значение всего выражения уже известно. Поскольку
&& означает логическое и, то если левая часть выражения && оказыва- ется ложной, все выражение также оказывается ложью, независимо от значения правой части. Для повышения эффективности, C++ ис- пользует этот факт, опуская расчет правой части выражения && , если левая часть оказывается ложной (для || или логического или правая часть по той же причине не рассчитывается, когда левая часть истин- на). Следовательно, когда loopPtr равен NULL, выражение loopPtr !=
NULL
ложно, и правая часть выражения && не рассчитывается. Без со- кращенных вычислений правая часть рассчитывалась бы и указатель на NULL разыменовывался бы, вызывая сбой программы.
Решение задач с классами 159
Эта реализация позволяет избежать потенциальной поломки, ха- рактерной для первой версии, однако она оказывает слишком много доверия клиентскому коду. То есть функция, вызывающая этот метод, должна проверить вернувшуюся обратно запись studentRecord и до выполнения дальнейших операций убедиться, что она не фиктивная.
Если вы похожи на меня, то вам должно быть немного не по себе .
.%4B/
Есть еще один способ. C++, как и многие другие языки программирования, пред-
лагает механизм, известный как исключения, позволяющий методу или глобальной
функции недвусмысленно сообщить об ошибке туда, откуда она была вызвана. Это
сделано для ситуации, которая сложилась в этом методе, когда нет хорошего отве-
та на вопрос, что возвращать, если ввод был некорректным. Исключения сложнее,
чем мы можем позволить себе рассматривать сейчас, и, к сожалению, реализация
исключений в C++ не решает проблему доверия, описанную в предыдущем абзаце.
Перегруппировка списка
Метод removeRecord похож на recordWithNumber в той, что мы должны пройти список для нахождения узла, который необходимо удалить.
Однако есть задачи сверх этого. Удаление узла из списка требует вни- мательности, поскольку остальные узлы должны остаться связным списком. Проще всего зашить сделанную нами дырочку соединени- ем узла, который шел перед удаленным, с тем, который шел следом за ним. Нам не нужно набрасывать функцию, потому что у нас уже есть прототип функции в объявлении класса, поэтому перей дем к тестам.
studentCollection s;
studentRecord stu3(84, 1152, "Sue");
studentRecord stu2(75, 4875, "Ed");
studentRecord stu1(98, 2938, "Todd");
s.addRecord(stu3);
s.addRecord(stu2);
s.addRecord(stu1);
X s.removeRecord(4875)
Мы создали объект s класса studentCollection, а также три объ- екта класса studentRecord, каждый из которых был добавлен в кол- лекцию. Обратите внимание, что мы могли бы использовать одну и ту же запись, меняя значения между вызовами addRecord, но не стали этого делать для упрощения тестового кода. Последняя строка в те- сте вызывает метод removeRecord
X
, который в данном случае удалит вторую запись, то есть запись для студента по имени «Ed». Используя тот же стиль схем указателей, что и в главе 4, проиллюстрируем на рис. 5.1 состояние памяти «до» и «после» этого вызова.
На рис. 5.1 (а) мы видим связный список, созданный нашим тесто- вым кодом. Обратите внимание, что, поскольку мы используем класс, наши соглашения о схемах слегка искажены. В левой части рисунка
160 Глава 5
изображен стек/куча, в котором находятся _listHead, являющийся приватным полем внутри объекта s класса studentCollection, и idNum, являющийся параметром метода removeRecord. В правой части рисун- ка изображен сам список в куче. Помните, что addRecord размещает новую запись в начало списка, поэтому записи расположены в обрат- ном порядке по сравнению с тем, как они добавлялись в тестовом коде. Идентификатор среднего узла, «Ed» совпадает с параметром
4875, поэтому он должен быть удален из списка. Рис. 5.1 (б) показыва- ет результат вызова метода. Первый узел списка, «Todd», теперь ука- зывает на узел, который ранее был в списке третьим, то есть на «Sue».
Узел «Ed» больше не связан с большим списком и удален.
Рис. 5.1. Ситуация «до» и «после» вызова метода
removeRecord в тестовом примере
Теперь, зная, какого результата мы хотим добиться, начнем пи- сать код. Поскольку мы знаем, что нам надо найти узел с соответ- ствующим идентификатором, мы могли бы начать с цикла while из метода recordWithNumber. По окончанию это цикла указатель будет указывать на узел, который надо удалить. К сожалению, нам надо кое-что еще, чтобы закончить удаление. Посмотрите на рис. 5.1. Что- бы закрыть дыру и восстановить связный список, мы должны изме- нить поле next узла «Todd». Если все, что у нас есть, это указатель на узел «Ed», способа указать на «Todd» не существует, потому что каждый узел в связном списке указывает на своего последователя, а не предшественника. (Из-за подобной ситуации некоторые связные списки связывают в обоих направлениях, такие списки называются
Решение задач с классами 161
двойные связные списки , но нужда в них возникает редко.) Таким об- разом, в дополнение к указателю на удаляемый узел (который назы- вается loopPtr, если мы применим код предыдущей функции), нам необходим указатель на предыдущий узел: давайте назовем такой указатель trailing. Рис. 5.2 демонстрирует эту концепции примени- мо к нашему примеру.
Рис. 5.2. Указатели, необходимые для удаления узла с номером
idNum
C указателями loopPtr ссылающимся на удаляемый узел и trail- ing
, ссылающимся на предыдущий узел, мы можем удалить желае- мый узел и сохранить список.
void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
X studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
Ytrailing = loopPtr;
loopPtr = loopPtr->next;
}
Z if (loopPtr == NULL) return;
[ trailing->next = loopPtr->next;
\ delete loopPtr;
}
Первая часть этой функции похожа на метод recordWithNumber, с тем исключением, что мы объявили указатель trailing
X
и внутри цикла присвоили ему старое значение loopPtr
Y
до перехода loopPtr к следующему узлу. Таким образом, указатель trailing всегда нахо- дится на один узел позади loopPtr. Благодаря нашей работе с пре- дыдущей функцией мы уже знаем про специальный случай. Поэтому, когда цикл закончится, мы проверяем, не равен ли loopPtr значе- нию NULL. Если равен, это означает, что мы не нашли узел с заданным идентификатором и немедленно возвращаемся
Z
. Я называю выра- жение return в середине функции «смываться». Некоторые програм- мисты возражают против этого, потому что функцию с несколькими точками выхода тяжело читать. Однако альтернативный вариант в этом случае еще один уровень вложения для инструкции if, которое идет следом, так что я лучше смоюсь.
Определив, что удаляемый узел существует, пора удалить его. На диаграмме видео, что необходимо установить, чтобы поле next узла
162 Глава 5
trailing указывало на узел, на который в настоящий момент указы- вает поле next узла loopPtr
[
. Затем мы можем безопасно удалить узел, на который указывает loopPtr
\
Этот код работает для нашего тестового примера, но, как всег- да, необходимо проверить его на возможных специальных случаях .
Мы уже разобрались с возможностью, что номер idNum отсутствует в записях нашей коллекции, но нет ли еще какой-нибудь проблемы?
Посмотрим на пример. Изменится ли что-нибудь, если мы попробу- ем удалить первый или третий узел вместо среднего? Тестирование показывает отсутствие проблем при удалении третьего (последнего) узла. Однако удаление первого узла и в самом деле вызывает труд- ности из-за того, что в этом случае отсутствует предыдущий узел, на который должен указывать trailing. Для этого мы должны мани- пулировать указателем _listHead. Рис. 5.3 иллюстрирует ситуацию, сложившуюся по окончании цикла while.
Рис. 5.3. Положение до удаления первого узла в списке
В этой ситуации нам нужно перенаправить _listHead на бывший второй узел в списке, то есть на узел «Ed». Давайте перепишем наш метод, чтобы решить данную проблему. void studentCollection::removeRecord(int idNum) {
studentNode * loopPtr = _listHead;
studentNode * trailing = NULL;
while (loopPtr != NULL && loopPtr->studentData.studentID() != idNum) {
trailing = loopPtr;
loopPtr = loopPtr->next;
}
if (loopPtr == NULL) return;
X if (trailing == NULL) {
Y _listHead = _listHead–>next;
} else {
trailing->next = loopPtr->next;
}
delete loopPtr;
}
Как видите, как проверка условия
X
, так и код, решающий про- блему
Y
, просты, поскольку мы тщательно проанализировали ситуа- цию, прежде чем писать его.
Решение задач с классами 163
Деструктор
Реализовав эти три метода, мы можем подумать, что класс stu- dentCollection готов. Однако это не так. Первое, чего не хватает нашему классу, это деструктор. Это специальный метод, вызывае- мый, когда объект выходит из области видимости (когда функция, объявившая этот объект, закончила работу). Если в классе отсут- ствуют динамические данные, обычно деструктор не нужен. Одна- ко если такие данные присутствуют, без деструктора не обойтись.
Помните, для того, чтобы не допустить утечек памяти, мы долж- ны применить ключевое слово delete для всех объектов, которым была выделена память с помощью ключевого слова new. Если объ- ект нашего класса studentCollection содержит три узла, необхо- димо освободить память, которую занимает каждый из них. К сча- стью, это несложно. Нам необходимо пройти по нашему связному списку, удаляя узлы. Однако вместо того, чтобы делать это непо- средственно, давайте напишем служебный метод, удаляющий все узлы в studentList. В приватной секции нашего класса добавим объявление:
void deleteList(studentList &listPtr);
Код для этого метода будет выглядеть так:
void studentCollection::deleteList(studentList &listPtr) {
while (listPtr != NULL) {
X studentNode * temp = listPtr;
Y listPtr = listPtr->next;
Z delete temp;
}
}
Во время обхода указатель на текущий узел копируется во вре- менную переменную
X
, передвигается к следующему узлу
Y
, а за- тем удаляется узел, на который указывает временная переменная
Z
С помощью этого кода мы можем написать деструктор очень просто.
Прежде всего, добавим деструктор в публичную секцию объявления нашего класса:
studentCollection();
Обратите внимание, что, как и у конструктора, имя деструктора совпадает с названием класса и в нем отсутствует возвращаемый тип.
Тильда перед именем позволяет отличить деструктор от конструкто- ра. Реализация будет выглядеть так:
studentCollection::studentCollection() {
deleteList(_listHead);
}
Код в этом методе прост, однако очень важно протестировать де- структор. Хотя плохо написанный деструктор и может вызвать сбой вашей программы, большинство проблем с деструктором вызывают
164 Глава 5
не ошибку программы, но утечки памяти или, что хуже, необъясни- мое поведение программы. Следовательно, важно протестировать деструктор с помощью отладчика среды разработки, так чтобы вы могли видеть, действительно ли деструктор вызывает delete для каждого узла.
Глубокое копирование
Осталась еще одна серьезная проблема. В главе 4 мы кратко обсу- дили концепцию перекрестных связей , когда две переменных типа указатель имеют одно и то же значение. Даже если это разные пе- ременные, они указывают на одну и ту же структуру данных; следо- вательно, изменение структуры одной переменной изменяет обе.
Эта проблема часто всплывает в классах, включающих динамиче- ски распределенную память. Чтобы увидеть, почему это проблема, рассмотрим следующий элементарный код на C++:
int x = 10;
int y = 15;
x = y;
X x = 5;
Предположим, я спросил бы вас, какое влияние последнее выра- жение
X
оказывает на переменную y. Вы, вероятно, удивились бы не оговорился ли я. Последнее выражение не оказывает совсем никако- го эффекта на y, только на x. А теперь посмотрите на это:
studentCollection s1;
studentCollection s2;
studentRecord r1(85, 99837, "John");
s2.addRecord(r1);
studentRecord r2(77, 4765, "Elsie");
s2.addRecord(r2);
X s1 = s2;
Y s2.removeRecord(99837)
Предположим, я спрошу вас, какое влияние окажет последнее вы- ражение
Y
на s1. К сожалению, влияние действительно будет. Хотя s1
и s2 — два разных объекта, они отныне не полностью отдельные объекты. По умолчанию, когда один объект приравнен к другому, как мы сейчас приравняли объекты s1 и s2
X
, язык C++ производит то, что известно как поверхностное копирование . При поверхностном ко- пировании каждое поле класса одного объекта присваивается друго- му. Так если, _listHead — наше единственное поле класса, публично, s2 = s1 будет аналогично s1._listHead = s2._listHead. Получае- тся, что поле _listHead обоих объектов указывает на одно и то же место в памяти: узел для «Elsie», который указывает на другой узел, а именно на узел «John». Поэтому после удаления узла «John», он уда- ляется из двух списков, поскольку на самом деле существует только один список. Рис. 5.4 иллюстрирует положение, сложившееся к кон- цу данного кода.
Решение задач с классами 165
Рис. 5.4. Результаты поверхностного копирования при перекрестных связях; удаление узла
«John» из одного списка удаляет из обоих
Однако все может быть еще хуже. Что, если последняя строка кода удалит первую запись — узел «Elsie»? В этом случае указатель _
listHead объекта s2 станет указывать на узел «John», а узел «Elsie» будет удален. Однако указатель _listHead объекта s1 по-прежнему будет указывать на удаленный узел «Elsie», то есть возникнет опасная висячая ссылка , как показано на рис. 5.5.
Рис. 5.5. Удаление из s2 приводит к висящей ссылке в s1
Для решения этой проблемы применяют глубокое копирование, то есть не только копирование указателя на структуру, но копирова- ние всей структуры. В нашем случае это означает копирование всех узлов списка. Тем самым происходит истинное копирование. Как и ранее, давайте начнем с приватных служебных методов, то есть в нашем случае с метода, копирующего studentList. Объявление в приватной секции класса выглядит примерно так:
studentList copiedList(const studentList original);
Как и ранее, я выбрал существительное для названия метода, возвра щающего значение. Реализация метода выглядит так:
X studentCollection::studentList studentCollection::copiedList(const studentList original) {
Y if (original == NULL) {
return NULL;
}
studentList newList = new studentNode;
Z newList->studentData = original->studentData;
[ studentNode * oldLoopPtr = original->next;
\ studentNode * newLoopPtr = newList;
while (oldLoopPtr != NULL) {
166 Глава 5
] newLoopPtr->next = new studentNode;
newLoopPtr = newLoopPtr->next;
newLoopPtr->studentData = oldLoopPtr->studentData;
oldLoopPtr = oldLoopPtr->next;
}
^ newLoopPtr->next = NULL;
_ return newList;
}
В этом методе происходит много всего, поэтому давайте будем раз- бираться постепенно. Прежде всего, обратим внимание, что, опреде- ляя тип возвращаемого значения, мы должны указать в префиксе имя класса
X
. В противном случае компилятор не поймет, о каком типе мы говорим. (Внутри метода это необязательно, поскольку компилятор уже знает, частью какого класса является метод – немного запутанно!)
Мы проверяем, не пуст ли входящий список. Если он пуст, смываем- ся
Y
. Теперь, зная, что у нас есть список для копирования, копируем данные первого узла до начала цикла
Z
, поскольку для этого узла нам необходимо изменить указатель на начало нового списка.
Затем мы устанавливаем два указателя для прохода по списку. Ука- затель oldLoopPtr
[
проходит через пришедший список; он всегда бу- дет указывать на узел, который мы собираемся копировать. Указатель newLoopPtr
\
проходит через новый, скопированный, список и всегда указывает на последний созданный узел, то есть узел, до которого мы будем добавлять следующий узел. Как и в методе removeRecord нам ну- жен здесь хвостовой указатель. В цикле
]
мы создаем новый узел, пе- редвигаем newLoopPtr, чтобы он указывал на него, копируем данные из старого узла в новый и передвигаем указатель oldLoopPtr. После цикла мы завершаем новый список, присваивая значение NULL полю next по- следнего узла
^
и возвращаем (return) указатель на новый список
_
Как же этот служебный метод решает обозначенную выше пробле- му? Сам по себе никак. Но, добавив этот код, мы можем перегрузить оператор присваивания. Перегрузка операторов — особенность языка
C++, позволяющая изменять действия, производимые встроенными операторами над определенными типами данных. В нашем случае мы хотим перегрузить оператор присваивания (=) так, чтобы вместо уста- новленного по умолчанию поверхностного копирования, он вызывал наш метод copiedList для выполнения глубокого копирования. В пуб- личной секции класса добавим следующее:
XstudentCollection& Yoperator=(Zconst studentCollection & [rhs);
Перегружаемый оператор определяется названием метода с ис- пользованием ключевого слова operator, за которым идет оператор, который мы хотим перегрузить
Y
. Имя выбранное мной для параме- тра (rhs
[
) обычный выбор для перегрузки операторов, поскольку это сокращение от словосочетания «правая сторона» (right-hand side).
Это помогает программистам упрощать себе жизнь. Так, в присвое- нии, о котором идет речь s2 = s1, объект s1 будет правой стороной оператора присваивания, а s2 — левой стороной . На правую часть мы
Решение задач с классами
1 ... 16 17 18 19 20 21 22 23 ... 34
167
ссылаемся через параметр, а на левую — непосредственно через поле класса, так же как и при любом другом методе класса. Итак, наша зада- ча состоит в создании списка, на который будет указывать _listHead и который будет являться копией списка, на который указывает _lis- tHead объекта rhs. Таким образом, при вызове s2 = s1 будет создан объект s2 как истинная копия объекта s1.
Тип параметра — это постоянная ссылка на наш класс
Z
. Тип воз- вращаемого значения — это всегда ссылка на класс
X
. Скоро вы увиди- те, почему параметр — это ссылка. Вас, возможно, удивляет, почему метод возвращает все подряд, начиная с того, что мы изменяем поле класса непосредственно в методе. Это происходит из-за того, что язык C++ позволяет присвоение по цепочке, например s3 = s2 = s1, в котором возвращаемое значение первого присвоения становится па- раметром следующего.
Теперь, когда синтаксис понятен, код оператора присваивания становится вполне очевидным:
studentCollection& studentCollection::operator=(const studentCollection &rhs) {
X if (this != &rhs) {
Y deleteList(_listHead);
Z _listHead = copiedList(rhs._listHead);
}
[ return * this;
}
Чтобы избежать утечки памяти, мы сначала должны удалить все узлы из левостороннего списка
Y
. (Именно для этой цели мы отдель- но написали вспомогательный метод deleteList, а не включили его код непосредственно в деструктор.) Удалив предыдущий левосторон- ний список, мы копируем правосторонний список, используя наш другой вспомогательный метод
Z
. Однако до выполнения этих шагов мы проверяем, что объект с правовой стороны отличается от объекта с левой (то есть, это не что-то подобное s1 = s2) проверяя, отлича- ются ли указатели
X
. Если указатели одинаковые, нет необходимости что-то делать, при этом это не вопрос эффективности. Если мы вы- полним глубокое копирование для одинаковых указателей, когда мы будем удалять узлы в левостороннем списке, мы также будем удалять узлы и в правостороннем. Наконец, мы возвращаем указатель на лево- сторонний объект
[
; это случится независимо от того, действительно ли скопировали мы что-нибудь или нет, потому что, хотя выражение, подобное s3 = s2 = s1, выглядит странным, мы хотим, чтобы оно рабо тало, если кто-нибудь попытается его выполнить.
Сделав служебный метод для копирования списка, мы должны создать и копирующий конструктор . Это конструктор, принимающий другой объект того же класса как объект. Копирующий конструктор вызывается явно, когда нам надо создать дубликат существующего списка studentCollection, но может вызываться и неявно, если объ- ект этого класса передается как параметр по значению в функцию.
Из-за этого вы должны предусмотреть передачу объекта по ссылке
168 Глава 5
с ключевым словом const вместо передачи по значению, за исклю- чением тех случаев, когда функция, получающая объект, должна из- менить его копию. В противном случае ваш код будет делать много ненужной работы. Например, рассмотрим коллекцию из 10 000 за- писей о студентах. Коллекция может быть передана по ссылке одним указателем. В противном случае копирующий конструктор будет вы- зываться каждый раз во время длительного прохода по коллекции и распределять память 10 000 раз. Затем для этих копий в конце рабо- ты функции будет вызван деструктор с очередным долгим проходом по коллекции высвобождением памяти 10 000 раз. Вот поэтому пра- восторонний параметр перегрузки оператора присваивания исполь- зует параметр по ссылке с ключевым словом const.
Для добавления копирующего конструктора в наш класс, доба- вим прежде его объявление в публичной секции объявления нашего класса.
studentCollection(const studentCollection &original);
Как и во всех конструкторах, здесь нет типа возвращаемого зна- чения. И, как и в случае перегрузки оператора присваивания, пере- даваемый параметр — это ссылка на наш класс с ключевым словом const
. Поскольку мы уже создали служебный метод, реализация бу- дет простой. studentCollection::studentCollection(const studentCollection &original) {
_listHead = copiedList(original._listHead);
}
Теперь мы можем выполнить подобное объявление:
studentCollection s2(s1);
Это объявление создает s2 и копирует туда все узлы s1.
Общий обзор классов с динамической памятью
Мы сделали многое в этом классе, завершив методы, указанные в ус- ловиях задачи, а теперь давайте взглянем, что у нас получилось. Вот так выглядит объявление нашего класса.
class studentCollection {
private:
struct studentNode {
studentRecord studentData;
studentNode * next;
};
public:
studentCollection();
studentCollection();
studentCollection(const studentCollection &original);
studentCollection& operator=(const studentCollection &rhs);
void addRecord(studentRecord newStudent);
studentRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
Решение задач с классами 169
private:
typedef studentNode * studentList;
studentList _listHead;
void deleteList(studentList &listPtr);
studentList copiedList(const studentList original);
};
Данный урок состоит в том, что при создании классов с динами- ческой памятью необходимо добавлять новые части. В дополнении к сущностям базового фреймворка класса — приватным данным, конструктору и методам отправления данных в объект и получе- ния их оттуда — мы должны добавить методы, работающие с выде- лением и высвобождением динамической памяти. Как минимум мы должны добавить конструктор и деструктор копирования, а также перегрузить оператор присваивания, если есть вероятность, что он кому-нибудь понадобится. Создание этих дополнительных мето- дов часто облегчается созданием служебных методов копирования или удаления динамических структур данных.
Кажется, что это большая работа. И это действительно мо- жет быть так. Но важно понимать, что со всем, что вы добавите в класс, вам так или иначе придется разбираться. Другими словами, даже если у нас нет класса для коллекции записей о студентах, ос- нованной на связном списке, мы все равно должны удалять узлы в списке после прохода по ним. Нам все равно придется думать о перекрестных связях, все равно придется проходить сквозь спи- сок и копировать узел за узлом, если нам нужна истинная копия исходного списка и так далее. Размещение всего в класс требует немного больше начальной подготовки, но как только все зара- ботает, клиентский код может не задумываться обо всех деталях, связанных с распределением памяти. Кроме того, инкапсуляции и сокрытие существенно облегчают работу со структурами дина- мических данных.
Ошибки, которых следует избегать
Мы поговорили о том, как создать хороший класс в языке C++, да- вайте теперь обсудим несколько подводных камней, которые следу- ет избегать.
Фальшивый класс
Как я уже говорил в начале этой главы, я считаю C++ отличным язы- ком для изучения объектно ориентированного программирования, поскольку это гибридный язык, включающий как процедурную, так и объектно ориентированную парадигму. В этом случае создание класса всегда является выбором программиста. В языках, подобных
Java, никогда не стоит вопрос: «Надо ли мне делать класс?» Вместо него возникает вопрос: «Как мне поместить это все в класс?» Тре- бование поместить все в класс приводит к появлению того, что я
170 Глава 5
называю фальшивым классом, то есть классом без последовательного проектирования, который корректен синтаксически, но не имеет смысла. Слово класс, как его используют программисты, соответ- ствует смыслу английского слова, означающего группу вещей с об- щими атрибутами, и хороший класс языка C++ соответствует этому определению.
Фальшивый класс может быть создан по нескольким причинам.
Например, программист хочет использовать глобальные перемен- ные, но не по обоснованным причинам (такие причины редки, но все-таки существуют), а из-за лени — чтобы избегать передачи пара- метров от функции к функции. При этом если программист пони- мает, что широкое использование глобальных переменных это при- знак плохого стиля, он или она полагает, что нашел лазейку. Все или большинство функций программы сваливают в класс, и теперь пере- менные, которые были бы глобальными, становятся полями этого класса. Функция main такой программы создает объект этого фаль- шивого класса и вызывает какой-то «главный» метод в классе. Тех- нически программа не использует глобальные переменные, но фаль- шивый класс означает, что этой программе присущи все недостатки программы с глобальными переменными.
Другой тип фальшивых классов возникает из-за того, что про- граммист полагает, что объектно ориентированное программиро- вание всегда «лучше», и применяет его в тех ситуациях, где оно не подходит. В этих случаях программист часто создает класс, который инкапсулирует специфический функционал, имеющий смысл толь- ко в контексте исходной программы, для которой он написан. Есть два способа проверить, пишете ли вы фальшивый класс или нет. За- дайте себе вопрос: «Могу я назвать класс кратко и понятно?» Если имя будет выглядеть как PayrollReportManagerAndPrintSpooler, по- видимому, у вас проблемы. Другой тест выглядит так: «если мне при- дется писать другую программу с похожим функционалом, можно ли этот класс использовать еще раз с небольшими изменениями? Или его придется кардинальным образом переписывать?»
Даже в языке C++ появление фальшивых классов неизбежно, на- пример, когда нам надо инкапсулировать данные для использования в классах-коллекциях. Однако такие классы обычно маленькие и эле- ментарные. Когда мы избегаем создания фальшивых классов, наш код становится лучше.
Однозадачники
Если вам доводилось смотреть телевизионное шоу Good Eats, вы зна- ете, что ведущий Элтон Браун тратит массу времени, рассуждая, как оборудовать кухню с максимальной эффективностью. Часто он вос- стает против кухонных приспособлений, которые называет одноза-
дачниками, подразумевая инструменты, хорошо решающие одну зада- чу, но не приспособленные более ни для чего. Разрабатывая классы,
Решение задач с классами 171
мы должны стремиться сделать их настолько общими, насколько это возможно, последовательно включая все функции необходимые для нашей программы.
Один из способов сделать это состоит в использовании шаблон- ных классов . Это продвинутый способ с немного загадочным син- таксисом, однако он позволяет нам создавать классы, где у одного или более полей тип определяется в момент создания объекта клас- са. Шаблонные классы позволяют нам «выделить» основной функ- ционал. Например, наш класс studentCollection содержит много кода одинакового для любого класса, инкапсулирующего связный список. Вместо этого мы можем сделать шаблонный класс для об- щего связного списка, такого, чтобы тип данных внутри узлов спи- ска определялся в момент создания объекта шаблонного класса, а не программировался как studentRecord. Тогда полем нашего клас- са studentCollection будет объект шаблонного класса связного спи- ска, а не указатель на начало списка, и наш класс больше не сможет непосредственно управлять связным списком.
Мы не будем рассматривать шаблонные классы в этой книге, од- нако, развивая ваши навыки проектировщика классов, вы должны всегда стремиться делать классы «многозадачниками». Когда вы об- наруживаете, что текущую задачу можно решить, используя класс, написанный тогда, когда вы даже не подозревали о ее существова- нии, вы испытываете чувство глубокого удовлетворения.
Упражнения
Вы знаете, что я хочу вам сказать, не так ли? Вперед, попытаетесь сами!
5.1.
Давайте попробуем реализовать класс, используя базовый фреймворк. Рассмотрим класс, хранящий данные по автомоби- лям. У нас есть три элемента данных: название производителя, название модели (оба строковые) и год выпуска – целочислен- ное. Создайте класс с методами get/set для каждого поля клас- са. Убедитесь, что вы приняли верные решения о деталях, на- пример имени переменных. Необязательно пользоваться моей конвенцией наименования, важнее грамотно подходить к при- нятию решений и быть в них последовательными.
5.2.
Добавьте в наш автомобильный класс из предыдущего упражне- ния служебный метод, возвращающий полное описание авто- мобиля в формате строки, например, «1957 Chevrolet Impala».
Добавьте второй служебный метод, возвращающий возраст ав- томобиля в годах.
5.3.
Возьмите функции для строк переменной длины из главы 4 (ap- pend
, concatenate и characterAt) и используйте их для создания класса для строк переменной длины. Убедитесь, что вы реализо- вали все необходимые конструкторы, деструктор, а также пере- грузили оператор присваивания.
5.4.
Для класса строк переменной длины из предыдущего упражне- ния замените метод characterAt перегруженным оператором [].
Например, если myString является объектом нашего класса, тог- да выражение myString[1] должно возвращать то же значение, что и myString.characterAt(1).
5.5.
Для класса строк переменной длины из предыдущих упражне- ний добавьте метод remove, принимающий начальную позицию и количество символов и удаляющий это количество символов из середины строки. Например, myString.remove(5,3) удалит три символа, начиная с пятой позиции. Убедитесь, что ваш метод ра- ботает, когда значение любого из параметров некор ректно.
5.6.
Подумайте, можно ли ваш класс строк переменной длины улуч- шить. Например, нет ли общего функционала, который можно выделить в приватный служебный метод?
5.7.
Возьмите функции записей о студентах из главы 4 (addRecord и averageRecord
) и используйте их для создания класса, представ- ляющего коллекцию записей о студентах, как ранее. Убедитесь, что реализовали все необходимые конструкторы, деструктор, а также перегрузили оператор присваивания.
5.8.
Классу коллекции записей о студентах из предыдущего упражне- ния добавьте метод RecordsWithinRange, принимающий нижнее и верхнее значение оценки как параметры и возвращающий но- вую коллекцию, состоящую из записей в этом диапазоне (исход- ная коллекция не меняется). Например, myCollection.Record- sWithinRange(75, 80)
вернет коллекцию всех записей с оценками в диапазоне от 75 до 80 включительно.
173
Эта глава посвящена рекурсии, при которой функция прямо или кос- венно вызывает саму себя. Рекур- сивное программирование выглядит так, будто оно должно быть простым. Действи- тельно, хорошее рекурсивное решение часто имеет простой, почти элегантный вид.
Тем не менее очень часто путь к этому решению весьма непрост. Это связано с тем, что рекурсия требует от нас мыслить не так, как в случае с другими ти- пами программирования. Когда мы обрабатываем данные с помощью циклов, мы думаем о последовательной обработке, однако при обра- ботке данных с использованием рекурсии, наш обычный процесс по- следовательного мышления не помогает. У многих хороших, но еще не оперившихся программистов возникают проблемы с рекурсией, поскольку они не могут найти способ применения уже освоенных ими
+ " ! =
6
174 Глава 6
навыков решения задач к задачам с рекурсией. В этой главе мы обсу- дим систематический подход к решению таких задач. Ответ заключа- ется в том, что мы будем называть Большой Рекурсивной Идеей, далее по тексту — БРИ. Эта идея настолько проста, что напоминает трюк, но она работает.
Обзор основ рекурсии
Синтаксис рекурсии не очень сложен. Трудность возникает, когда вы пытаетесь использовать рекурсию для решения задач. Рекурсия име- ет место всегда, когда функция сама себя вызывает, поэтому синтак- сис рекурсии представляет собой просто синтаксис вызова функции.
Наиболее распространенной формой является прямая рекурсия, ког- да вызов функции происходит в теле этой же функции. Например:
int factorial(int n) {
X if (n == 1) return 1;
else return n *
Yfactorial(n — 1);
}
Эта функция, которая является общей, но неэффективной демон- страцией рекурсии, вычисляет факториал числа n. Например, если n равно 5, то факториал — это произведение всех чисел от 5 до 1, или 120.
Обратите внимание на то, что в некоторых случаях рекурсии не проис- ходит. В этой функции, если параметр равен 1, мы просто возвращаем значение напрямую без какой-либо рекурсии
X
, это так называемый ба-
зовый случай. В противном случае мы делаем рекурсивный вызов
Y
Другой формой рекурсии является косвенная рекурсия — напри- мер, когда функция A вызывает функцию B, которая далее вызывает функцию A. Косвенная рекурсия редко используется в качестве ме- тода решения задач, поэтому мы не будем здесь ее обсуждать.
Головная и хвостовая рекурсия
Прежде чем обсуждать БРИ, нам нужно понять разницу между голов- ной и хвостовой рекурсией. В случае головной рекурсии рекурсивный вызов, когда он происходит, предваряет другие процессы обработки в функции (считайте, что он происходит вверху или в голове функции).
В случае хвостовой рекурсии все наоборот — обработка происходит пе- ред рекурсивным вызовом. Выбор между двумя рекурсивными стиля- ми может показаться произвольным, однако в этом выборе заключа- ется все различие. Чтобы проиллюстрировать это различие, давайте рассмотрим две задачи.
:
Пассажиры Райской тропической железной дороги (РТЖД) с нетерпением ждут того мо- мента, когда смогут увидеть из окон поезда многочисленных красочных птиц. По этой причине руководство железной дороги очень заинтересовано в состоянии здоровья
Решение задач с помощью рекурсии 175
местной популяции попугаев и решает подсчитать количество попугаев, находящихся в пределах видимости с каждой железнодорожной платформы вдоль главной линии. На каждую платформу направлен сотрудник РТЖД (см. рис. 6.1), который, безусловно, спо- собен подсчитать количество попугаев. К сожалению, работа усложняется из-за прими- тивной телефонной системы. С каждой платформы можно позвонить только на соседние платформы. Как передать общее число попугаев на терминал главной линии?
Рис. 6.1. Сотрудники пяти станций могут общаться только со своими непосредственными
соседями
Предположим, что Арт насчитал на главном терминале 7 попугаев,
Белинда — 5 попугаев, Кори — 3 попугая, Дебби — 10 попугаев, и 2 попу- гая насчитал Эван на последней станции. Таким образом, общее число попугаев составляет 27. Вопрос в том, как сотрудники собираются ра- ботать вместе, чтобы сообщить об этом Арту? Любое решение этой за- дачи потребует последовательной передачи сообщения от основного терминала до конца линии и обратно. Сотрудник на каждой платформе должен будет подсчитать количество попугаев, а затем сообщить о сво- их наблюдениях. Тем не менее существуют два разных подхода к реа- лизации этой цепочки сообщений, которые соответствуют головной и хвостовой рекурсии в программировании.
1 ... 17 18 19 20 21 22 23 24 ... 34
168 Глава 5
с ключевым словом const вместо передачи по значению, за исклю- чением тех случаев, когда функция, получающая объект, должна из- менить его копию. В противном случае ваш код будет делать много ненужной работы. Например, рассмотрим коллекцию из 10 000 за- писей о студентах. Коллекция может быть передана по ссылке одним указателем. В противном случае копирующий конструктор будет вы- зываться каждый раз во время длительного прохода по коллекции и распределять память 10 000 раз. Затем для этих копий в конце рабо- ты функции будет вызван деструктор с очередным долгим проходом по коллекции высвобождением памяти 10 000 раз. Вот поэтому пра- восторонний параметр перегрузки оператора присваивания исполь- зует параметр по ссылке с ключевым словом const.
Для добавления копирующего конструктора в наш класс, доба- вим прежде его объявление в публичной секции объявления нашего класса.
studentCollection(const studentCollection &original);
Как и во всех конструкторах, здесь нет типа возвращаемого зна- чения. И, как и в случае перегрузки оператора присваивания, пере- даваемый параметр — это ссылка на наш класс с ключевым словом const
. Поскольку мы уже создали служебный метод, реализация бу- дет простой. studentCollection::studentCollection(const studentCollection &original) {
_listHead = copiedList(original._listHead);
}
Теперь мы можем выполнить подобное объявление:
studentCollection s2(s1);
Это объявление создает s2 и копирует туда все узлы s1.
Общий обзор классов с динамической памятью
Мы сделали многое в этом классе, завершив методы, указанные в ус- ловиях задачи, а теперь давайте взглянем, что у нас получилось. Вот так выглядит объявление нашего класса.
class studentCollection {
private:
struct studentNode {
studentRecord studentData;
studentNode * next;
};
public:
studentCollection();
studentCollection();
studentCollection(const studentCollection &original);
studentCollection& operator=(const studentCollection &rhs);
void addRecord(studentRecord newStudent);
studentRecord recordWithNumber(int idNum);
void removeRecord(int idNum);
Решение задач с классами 169
private:
typedef studentNode * studentList;
studentList _listHead;
void deleteList(studentList &listPtr);
studentList copiedList(const studentList original);
};
Данный урок состоит в том, что при создании классов с динами- ческой памятью необходимо добавлять новые части. В дополнении к сущностям базового фреймворка класса — приватным данным, конструктору и методам отправления данных в объект и получе- ния их оттуда — мы должны добавить методы, работающие с выде- лением и высвобождением динамической памяти. Как минимум мы должны добавить конструктор и деструктор копирования, а также перегрузить оператор присваивания, если есть вероятность, что он кому-нибудь понадобится. Создание этих дополнительных мето- дов часто облегчается созданием служебных методов копирования или удаления динамических структур данных.
Кажется, что это большая работа. И это действительно мо- жет быть так. Но важно понимать, что со всем, что вы добавите в класс, вам так или иначе придется разбираться. Другими словами, даже если у нас нет класса для коллекции записей о студентах, ос- нованной на связном списке, мы все равно должны удалять узлы в списке после прохода по ним. Нам все равно придется думать о перекрестных связях, все равно придется проходить сквозь спи- сок и копировать узел за узлом, если нам нужна истинная копия исходного списка и так далее. Размещение всего в класс требует немного больше начальной подготовки, но как только все зара- ботает, клиентский код может не задумываться обо всех деталях, связанных с распределением памяти. Кроме того, инкапсуляции и сокрытие существенно облегчают работу со структурами дина- мических данных.
Ошибки, которых следует избегать
Мы поговорили о том, как создать хороший класс в языке C++, да- вайте теперь обсудим несколько подводных камней, которые следу- ет избегать.
Фальшивый класс
Как я уже говорил в начале этой главы, я считаю C++ отличным язы- ком для изучения объектно ориентированного программирования, поскольку это гибридный язык, включающий как процедурную, так и объектно ориентированную парадигму. В этом случае создание класса всегда является выбором программиста. В языках, подобных
Java, никогда не стоит вопрос: «Надо ли мне делать класс?» Вместо него возникает вопрос: «Как мне поместить это все в класс?» Тре- бование поместить все в класс приводит к появлению того, что я
170 Глава 5
называю фальшивым классом, то есть классом без последовательного проектирования, который корректен синтаксически, но не имеет смысла. Слово класс, как его используют программисты, соответ- ствует смыслу английского слова, означающего группу вещей с об- щими атрибутами, и хороший класс языка C++ соответствует этому определению.
Фальшивый класс может быть создан по нескольким причинам.
Например, программист хочет использовать глобальные перемен- ные, но не по обоснованным причинам (такие причины редки, но все-таки существуют), а из-за лени — чтобы избегать передачи пара- метров от функции к функции. При этом если программист пони- мает, что широкое использование глобальных переменных это при- знак плохого стиля, он или она полагает, что нашел лазейку. Все или большинство функций программы сваливают в класс, и теперь пере- менные, которые были бы глобальными, становятся полями этого класса. Функция main такой программы создает объект этого фаль- шивого класса и вызывает какой-то «главный» метод в классе. Тех- нически программа не использует глобальные переменные, но фаль- шивый класс означает, что этой программе присущи все недостатки программы с глобальными переменными.
Другой тип фальшивых классов возникает из-за того, что про- граммист полагает, что объектно ориентированное программиро- вание всегда «лучше», и применяет его в тех ситуациях, где оно не подходит. В этих случаях программист часто создает класс, который инкапсулирует специфический функционал, имеющий смысл толь- ко в контексте исходной программы, для которой он написан. Есть два способа проверить, пишете ли вы фальшивый класс или нет. За- дайте себе вопрос: «Могу я назвать класс кратко и понятно?» Если имя будет выглядеть как PayrollReportManagerAndPrintSpooler, по- видимому, у вас проблемы. Другой тест выглядит так: «если мне при- дется писать другую программу с похожим функционалом, можно ли этот класс использовать еще раз с небольшими изменениями? Или его придется кардинальным образом переписывать?»
Даже в языке C++ появление фальшивых классов неизбежно, на- пример, когда нам надо инкапсулировать данные для использования в классах-коллекциях. Однако такие классы обычно маленькие и эле- ментарные. Когда мы избегаем создания фальшивых классов, наш код становится лучше.
Однозадачники
Если вам доводилось смотреть телевизионное шоу Good Eats, вы зна- ете, что ведущий Элтон Браун тратит массу времени, рассуждая, как оборудовать кухню с максимальной эффективностью. Часто он вос- стает против кухонных приспособлений, которые называет одноза-
дачниками, подразумевая инструменты, хорошо решающие одну зада- чу, но не приспособленные более ни для чего. Разрабатывая классы,
Решение задач с классами 171
мы должны стремиться сделать их настолько общими, насколько это возможно, последовательно включая все функции необходимые для нашей программы.
Один из способов сделать это состоит в использовании шаблон- ных классов . Это продвинутый способ с немного загадочным син- таксисом, однако он позволяет нам создавать классы, где у одного или более полей тип определяется в момент создания объекта клас- са. Шаблонные классы позволяют нам «выделить» основной функ- ционал. Например, наш класс studentCollection содержит много кода одинакового для любого класса, инкапсулирующего связный список. Вместо этого мы можем сделать шаблонный класс для об- щего связного списка, такого, чтобы тип данных внутри узлов спи- ска определялся в момент создания объекта шаблонного класса, а не программировался как studentRecord. Тогда полем нашего клас- са studentCollection будет объект шаблонного класса связного спи- ска, а не указатель на начало списка, и наш класс больше не сможет непосредственно управлять связным списком.
Мы не будем рассматривать шаблонные классы в этой книге, од- нако, развивая ваши навыки проектировщика классов, вы должны всегда стремиться делать классы «многозадачниками». Когда вы об- наруживаете, что текущую задачу можно решить, используя класс, написанный тогда, когда вы даже не подозревали о ее существова- нии, вы испытываете чувство глубокого удовлетворения.
Упражнения
Вы знаете, что я хочу вам сказать, не так ли? Вперед, попытаетесь сами!
5.1.
Давайте попробуем реализовать класс, используя базовый фреймворк. Рассмотрим класс, хранящий данные по автомоби- лям. У нас есть три элемента данных: название производителя, название модели (оба строковые) и год выпуска – целочислен- ное. Создайте класс с методами get/set для каждого поля клас- са. Убедитесь, что вы приняли верные решения о деталях, на- пример имени переменных. Необязательно пользоваться моей конвенцией наименования, важнее грамотно подходить к при- нятию решений и быть в них последовательными.
5.2.
Добавьте в наш автомобильный класс из предыдущего упражне- ния служебный метод, возвращающий полное описание авто- мобиля в формате строки, например, «1957 Chevrolet Impala».
Добавьте второй служебный метод, возвращающий возраст ав- томобиля в годах.
5.3.
Возьмите функции для строк переменной длины из главы 4 (ap- pend
, concatenate и characterAt) и используйте их для создания класса для строк переменной длины. Убедитесь, что вы реализо- вали все необходимые конструкторы, деструктор, а также пере- грузили оператор присваивания.
5.4.
Для класса строк переменной длины из предыдущего упражне- ния замените метод characterAt перегруженным оператором [].
Например, если myString является объектом нашего класса, тог- да выражение myString[1] должно возвращать то же значение, что и myString.characterAt(1).
5.5.
Для класса строк переменной длины из предыдущих упражне- ний добавьте метод remove, принимающий начальную позицию и количество символов и удаляющий это количество символов из середины строки. Например, myString.remove(5,3) удалит три символа, начиная с пятой позиции. Убедитесь, что ваш метод ра- ботает, когда значение любого из параметров некор ректно.
5.6.
Подумайте, можно ли ваш класс строк переменной длины улуч- шить. Например, нет ли общего функционала, который можно выделить в приватный служебный метод?
5.7.
Возьмите функции записей о студентах из главы 4 (addRecord и averageRecord
) и используйте их для создания класса, представ- ляющего коллекцию записей о студентах, как ранее. Убедитесь, что реализовали все необходимые конструкторы, деструктор, а также перегрузили оператор присваивания.
5.8.
Классу коллекции записей о студентах из предыдущего упражне- ния добавьте метод RecordsWithinRange, принимающий нижнее и верхнее значение оценки как параметры и возвращающий но- вую коллекцию, состоящую из записей в этом диапазоне (исход- ная коллекция не меняется). Например, myCollection.Record- sWithinRange(75, 80)
вернет коллекцию всех записей с оценками в диапазоне от 75 до 80 включительно.
173
Эта глава посвящена рекурсии, при которой функция прямо или кос- венно вызывает саму себя. Рекур- сивное программирование выглядит так, будто оно должно быть простым. Действи- тельно, хорошее рекурсивное решение часто имеет простой, почти элегантный вид.
Тем не менее очень часто путь к этому решению весьма непрост. Это связано с тем, что рекурсия требует от нас мыслить не так, как в случае с другими ти- пами программирования. Когда мы обрабатываем данные с помощью циклов, мы думаем о последовательной обработке, однако при обра- ботке данных с использованием рекурсии, наш обычный процесс по- следовательного мышления не помогает. У многих хороших, но еще не оперившихся программистов возникают проблемы с рекурсией, поскольку они не могут найти способ применения уже освоенных ими
+ " ! =
6
174 Глава 6
навыков решения задач к задачам с рекурсией. В этой главе мы обсу- дим систематический подход к решению таких задач. Ответ заключа- ется в том, что мы будем называть Большой Рекурсивной Идеей, далее по тексту — БРИ. Эта идея настолько проста, что напоминает трюк, но она работает.
Обзор основ рекурсии
Синтаксис рекурсии не очень сложен. Трудность возникает, когда вы пытаетесь использовать рекурсию для решения задач. Рекурсия име- ет место всегда, когда функция сама себя вызывает, поэтому синтак- сис рекурсии представляет собой просто синтаксис вызова функции.
Наиболее распространенной формой является прямая рекурсия, ког- да вызов функции происходит в теле этой же функции. Например:
int factorial(int n) {
X if (n == 1) return 1;
else return n *
Yfactorial(n — 1);
}
Эта функция, которая является общей, но неэффективной демон- страцией рекурсии, вычисляет факториал числа n. Например, если n равно 5, то факториал — это произведение всех чисел от 5 до 1, или 120.
Обратите внимание на то, что в некоторых случаях рекурсии не проис- ходит. В этой функции, если параметр равен 1, мы просто возвращаем значение напрямую без какой-либо рекурсии
X
, это так называемый ба-
зовый случай. В противном случае мы делаем рекурсивный вызов
Y
Другой формой рекурсии является косвенная рекурсия — напри- мер, когда функция A вызывает функцию B, которая далее вызывает функцию A. Косвенная рекурсия редко используется в качестве ме- тода решения задач, поэтому мы не будем здесь ее обсуждать.
Головная и хвостовая рекурсия
Прежде чем обсуждать БРИ, нам нужно понять разницу между голов- ной и хвостовой рекурсией. В случае головной рекурсии рекурсивный вызов, когда он происходит, предваряет другие процессы обработки в функции (считайте, что он происходит вверху или в голове функции).
В случае хвостовой рекурсии все наоборот — обработка происходит пе- ред рекурсивным вызовом. Выбор между двумя рекурсивными стиля- ми может показаться произвольным, однако в этом выборе заключа- ется все различие. Чтобы проиллюстрировать это различие, давайте рассмотрим две задачи.
:
Пассажиры Райской тропической железной дороги (РТЖД) с нетерпением ждут того мо- мента, когда смогут увидеть из окон поезда многочисленных красочных птиц. По этой причине руководство железной дороги очень заинтересовано в состоянии здоровья
Решение задач с помощью рекурсии 175
местной популяции попугаев и решает подсчитать количество попугаев, находящихся в пределах видимости с каждой железнодорожной платформы вдоль главной линии. На каждую платформу направлен сотрудник РТЖД (см. рис. 6.1), который, безусловно, спо- собен подсчитать количество попугаев. К сожалению, работа усложняется из-за прими- тивной телефонной системы. С каждой платформы можно позвонить только на соседние платформы. Как передать общее число попугаев на терминал главной линии?
Рис. 6.1. Сотрудники пяти станций могут общаться только со своими непосредственными
соседями
Предположим, что Арт насчитал на главном терминале 7 попугаев,
Белинда — 5 попугаев, Кори — 3 попугая, Дебби — 10 попугаев, и 2 попу- гая насчитал Эван на последней станции. Таким образом, общее число попугаев составляет 27. Вопрос в том, как сотрудники собираются ра- ботать вместе, чтобы сообщить об этом Арту? Любое решение этой за- дачи потребует последовательной передачи сообщения от основного терминала до конца линии и обратно. Сотрудник на каждой платформе должен будет подсчитать количество попугаев, а затем сообщить о сво- их наблюдениях. Тем не менее существуют два разных подхода к реа- лизации этой цепочки сообщений, которые соответствуют головной и хвостовой рекурсии в программировании.
1 ... 17 18 19 20 21 22 23 24 ... 34
Подход 1
При этом подходе мы сохраняем промежуточную сумму попугаев по мере продвижения по цепи исходящих сообщений. Каждый сотрудник, связываясь с сотрудником на следующей станции, сообщает количество виденных до сих пор попугаев. Когда мы доберемся до конца линии,
Эван первым узнает общее количество попугаев, которое он передаст
Дебби, которая передаст его Кори и т.д. (как показано на рис. 6.2).
Рис. 6.2. Нумерация шагов, предпринятых при использовании подхода 1 для решения за-
дачи подсчета попугаев
1. АРТ начинает с подсчета попугаев вокруг своей платформы. Он насчитывает 7 попугаев.
2. АРТ БЕЛИНДЕ: «На главном терминале 7 попугаев».
176 Глава 6 3. БЕЛИНДА насчитывает 5 попугаев вокруг своей платформы, промежуточная сумма равна 12.
4. БЕЛИНДА КОРИ: «Вокруг первых двух станций 12 попугаев».
5. КОРИ начитывает 3 попугая.
6. КОРИ ДЕББИ: «Вокруг первых трех станций 15 попугаев».
7. ДЕББИ насчитывает 10 попугаев.
8. ДЕББИ ЭВАНУ: «Вокруг первых четырех станций 25 попугаев».
9. ЭВАН насчитывает 2 попугая и обнаруживает, что общее количе- ство попугаев составляет 27.
10. ЭВАН ДЕББИ: «Общее количество попугаев — 27».
11. ДЕББИ КОРИ: «Общее количество попугаев — 27».
12. КОРИ БЕЛИНДЕ: «Общее количество попугаев — 27».
13. БЕЛИНДА АРТУ: «Общее количество попугаев — 27».
Этот подход аналогичен хвостовой рекурсии. В хвостовой рекур- сии рекурсивный вызов происходит после обработки — рекурсивный вызов является последним шагом в функции. Обратите внимание, что в вышеприведенной цепочке сообщений «работа» сотрудников (подсчет попугаев и суммирование) имеет место до передачи сообщения следу- ющему сотруднику. Вся работа происходит в цепочке исходящих, а не входящих сообщений. Вот шаги, выполняемые каждым из сотрудников.
1. Подсчет попугаев, видимых с платформы станции.
2. Добавление полученного значения к сумме, переданной с преды- дущей станции.
3. Передача промежуточной суммы попугаев на следующую станцию.
4. Ожидание передачи общей суммы попугаев со следующей стан- ции и передача этого значения на предыдущую станцию.
Подход 2
При использовании этого подхода мы суммируем количества попу- гаев с другого конца. Каждый сотрудник, связываясь со следующей станцией вниз по линии, запрашивает общее количество попугаев с этой станции. Затем этот сотрудник прибавляет количество попу- гаев, виденных со своей собственной станции, и передает эту новую сумму вверх по линии (как показано на рис. 6.3).
Рис. 6.3. Нумерация шагов, предпринятых при использовании подхода 2 для решения за-
дачи подсчета попугаев.
Решение задач с помощью рекурсии 177
1. АРТ БЛИНДЕ: «Каково общее количество попугаев с вашей стан- ции до конца линии?»
2. БЕЛИНДА КОРИ: «Каково общее количество попугаев с вашей станции до конца линии?»
3. КОРИ ДЕББИ: «Каково общее количество попугаев с вашей станции до конца линии?»
4. ДЕББИ ЭВАНУ: «Каково общее количество попугаев с вашей станции до конца линии?»
5. ЭВАН находится в конце линии. Он насчитывает 2 попугая.
6. ЭВАН ДЕББИ: «Общее количество попугаев здесь в конце ли- нии — 2».
7. ДЕББИ насчитывает 10 попугаев на своей станции, поэтому об- щая сумма от ее станции до конца линии составляет 12.
8. ДЕББИ КОРИ: «Общее количество попугаев отсюда до конца равно 12».
9. КОРИ насчитывает 3 попугая.
10. КОРИ БЕЛИНДЕ: «Общее количество попугаев отсюда до конца равно 15».
11. БЕЛИНДА насчитывает 5 попугаев.
12. БЕЛИНДА АРТУ: «Общее количество попугаев отсюда до конца равно 20».
13. АРТ насчитывает 7 попугаев на главном терминале, что состав- ляет в общей сложности 27.
Этот подход аналогичен головной рекурсии. При головной ре- курсии рекурсивный вызов происходит перед прочими операциями обработки. В данном случае вызов следующей станции происходит до подсчета попугаев или суммирования. «Работа» откладывается до тех пор, пока сотрудники станций, расположенных вниз по линии, не сообщат свои итоговые значения. Ниже перечислены шаги, кото- рые выполняет каждый из сотрудников.
1. Вызов следующей станции.
2. Подсчет попугаев, видимых с платформы станции.
3. Добавление полученного значения к сумме, переданной со сле- дующей станции.
4. Передача итоговой суммы на предыдущую станцию.
Возможно, вы заметили два практических эффекта от использо- вания разных подходов. При использовании первого подхода в ко- нечном итоге сотрудники всех станций узнают общее количество попугаев. При использовании второго подхода только Арт, работа- ющий на главном терминале, узнает итоговое значение, однако за- метьте, что Арт является единственным сотрудником, которому тре- буется это итоговое значение.
178 Глава 6
Другой практический эффект станет более важным для нашего анализа, когда мы переключим внимание на конкретный програм- мный код. При использовании первого подхода каждый сотрудник передает «промежуточную сумму» на следующую станцию вниз по линии при выполнении запроса. При использовании второго подхо- да сотрудник просто запрашивает информацию со следующей стан- ции, не передавая какие-либо данные вниз по линии. Этот эффект типичен для головной рекурсии. Поскольку рекурсивный вызов предваряет любые другие операции обработки, новая информация не передается рекурсивному вызову. В общем, головная рекурсия по- зволяет передать рекурсивному вызову минимальный набор данных.
Теперь давайте рассмотрим еще одну задачу.
: #
Менеджер DelegateCorp должен определить, какой из восьми клиентов приносит его компании наибольшую прибыль. Два фактора усложняют эту в прочих отношениях про- стую задачу. Во-первых, определение общей прибыли от клиента требует перебора всех данных этого клиента и подсчета значений на десятков заказов и квитанций. Во-вторых, сотрудники DelegateCorp, как следует из названия, любят делегировать, и каждый со- трудник при любой возможности старается передать задачу своему подчиненному.
Чтобы ситуация не вышла из-под контроля, менеджер устанавливает правило, согласно которому при делегировании сотрудник должен выполнить часть работы самостоятельно и поручить подчиненному меньше работы, чем досталось ему самому.
В табл. 6.1 и 6.2 перечислены сотрудники и клиенты DelegateCorp.
Табл. 6.1. Должности и ранги сотрудников DelegateCorp
%›…%“2
p=…
Менеджер
1
Вице-менеджер
2
Заместитель менеджера
3
Помощник менеджера
4
Младший менеджер
5
Стажер
6
Табл. 6.1. Клиенты DelegateCorp
%! ,…2=
!,K/
№0001
$172 000
№0002
$68 000
№0003
$193 000
№0004
$13 000
№0005
$256 000
№0006
$99 000
Исходя из установленного компанией правила делегирования работы, вот что произойдет с шестью файлами клиентов. Менед- жер возьмет один файл и определит, какую прибыль для компа-