Глава 20. НАСЛЕДОВАНИЕ КЛАССОВ...233
Глава 21. ЗНАКОМСТВО С ВИРТУАЛЬНЫМИ ФУНКЦИЯМИ-ЧЛЕНАМИ: НАСТОЯЩИЕ ЛИ ОНИ...240
Глава 22. РАЗЛОЖЕНИЕ КЛАССОВ...249
В этой части...
Из дискуссии по вопросам объектно-ориентированной философии в части 3 становится ясно, что в реальном мире существует две вещи, которые нельзя выразить с помощью функционально-ориентированных программ.
Первое — это возможность работы с отдельными объектами. Я привёл пример использования микроволновой печи для приготовления закуски. Она предоставляет интерфейс ( на лицевой панели ), который я использую для управления, совершенно не вникая в подробности работы печи. Я буду вести себя точно так же, даже если буду знать всё о том, как именно она устроена ( хотя я этого не знаю ).
Второй аспект реального мира, закрытый для функциональных программ, — это классификация объектов: распознавание и использование их подобия. Если в рецепте приготовления того или иного блюда указана печь любого типа, то, работая с микроволновой печью, я буду уверен, что использую правильное устройство, поскольку микроволновая печь является одним из типов печей.
В предыдущей части вы познакомились с механизмом, используемым в С++ для осуществления первой возможности объектно-ориентированного программирования, — с классами. Для обеспечения второй возможности С++ использует концепцию, называемую наследованием, которая расширяет понятие и возможности классов. Именно о наследовании и пойдёт речь в этой части книги.
_________________
232 стр. Часть 4. Наследование
В этой главе...
В этой главе обсуждается наследование ( inheritance ), т.е. способность одного класса наследовать возможности или свойства другого класса. Наследование — это общепринятая концепция. Я — человек ( за исключением раннего утра... ). И я наследую некоторые свойства класса Человек, например возможность говорить ( в большей или меньшей степени ), интеллект ( надеюсь, что в большей степени ), необходимость в воздухе, воде, пище и разных витаминах. Эти свойства не являются уникальными для каждого отдельного человека. Очевидно, что класс Человек наследует зависимость от воды, воздуха и пищи у класса Млекопитающие, который, в свою очередь, наследует эти свойства у класса Животные.
Концепция, в основе которой лежит способность передавать свойства по наследству, очень мощная. Благодаря ей можно значительно сэкономить место при описании реального объекта. Например, если мой сын спросит: "Что такое утка?", я смогу сказать: "Это птица, которая крякает". Несмотря на краткость, этот ответ несёт в себе всю необходимую для описания утки ( по крайней мере, для моего сына ) информацию. Мой сын знает, что такое птица, и может понять, что утке присущи все свойства птицы плюс свойство "кряканье".
В объектно-ориентированных языках такая наследственная связь выражается в возможности одного класса наследовать другой. Таким образом, объектно-ориентированные языки позволяют создавать модели, более близкие к реальному миру ( а именно для этого они и созданы ), чем модели, построенные с помощью языков, не поддерживающих наследование. В С++ один класс может наследовать другой следующим образом:
class Student
{
} ;
class GraduateStudent : public Student
{
} ;
В этом примере GraduateStudent наследует все члены класса Student. Таким образом, GraduateStudent ЯВЛЯЕТСЯ студентом ( использование прописных букв должно подчеркнуть важность этого отношения ). Конечно, при этом GraduateStudent может также содержать уникальные, присущие именно ему члены.
_________________
233 стр. Глава 20. Наследование классов
Наследование было включено в С++ по нескольким причинам. Конечно, основной из них была необходимость выражать связи между классами с помощью наследования ( к этому я ещё вернусь ). Менее важной целью было уменьшение размера исходного кода. Представьте себе, что у вас есть класс Student и вас попросили добавить новый класс под названием GraduateStudent. В этом случае наследование значительно уменьшит количество членов, которые вам придётся добавлять в класс. Всё, что вам действительно нужно в классе GraduateStudent, — это члены, которые будут описывать отличия между студентами и аспирантами.
¦¦¦¦¦¦¦¦¦¦«
Это потрясающе
Люди составляют обширные системы, чтобы было проще разбираться в том, что их окружает. Тузик является частным случаем собаки, которая является частным случаем собакообразных, которые входят в состав млекопитающих, и т.д. Так легче познавать мир.
Если использовать другой пример, можно сказать, что студент является человеком ( точнее, его частным случаем ). Как только это сказано, я уже знаю довольно много о студентах ( об американских студентах, естественно ). Я знаю, что они имеют номера социального страхования, что они слишком много смотрят телевизор и постоянно мечтают о сексе. Я знаю всё это потому, что это свойства всех людей. В С++ мы говорим, что класс student наследует класс Person. Кроме того, мы говорим, что Person является базовым классом для класса student. Наконец, мы говорим, что student ЯВЛЯЕТСЯ Person ( использование прописных букв — общепринятый метод отражения уникального типа связи; не я это придумал ). Эта терминология используется в С++ и других объектно-ориентированных языках программирования.
Заметьте, что хотя Student и ЯВЛЯЕТСЯ Person, обратное не верно. Person не ЯВЛЯЕТСЯ Student ( такое выражение следует трактовать в общем смысле, поскольку конкретный человек, конечно же, может оказаться студентом ). Существует много людей, которые являются членами класса Person и не являются членами класса student. Кроме того, класс student имеет средний балл, a Person его не имеет.
Свойство наследования транзитивно. Например, если я определю новый класс GraduateStudent как подкласс класса student, то он тоже будет наследником person. Это значит, что будет выполняться следующее: если GraduateStudent ЯВЛЯЕТСЯ Student и Student ЯВЛЯЕТСЯ Person, то GraduateStudent ЯВЛЯЕТСЯ Person.
»¦¦¦¦¦¦¦¦¦¦
Ещё один небольшой побочный эффект связан с изменениями, вносимыми в программное обеспечение. Предположим, что вы выполняете наследование некоторого существующего класса. Позже выясняется, что базовый класс работает не совсем так, как требуется порождённому классу, или что в нём имеется ошибка. Изменение базового класса может привести к неработоспособности всего кода, который использует этот базовый класс.
«Здесь приведён пример уже рассмотренного класса GraduateStudent, который дополнен несколькими членами.»
[Диск]
/* InheritanceExample — пример наследования, при */
/* котором конструктор наследника */
/* передаёт информацию конструктору базового класса */
_________________
234 стр. Часть 4. Наследование
#include
#include
#include
#include
using namespace std ;
/* Advisor — пустой класс */
class Advisor { } ;
const int MAXNAMESIZE = 40 ;
class Student
{
public :
Student( char *pName = "no name" )
: average( 0.0 ) , semesterHours( 0 )
{
strncpy( name , pName , MAXNAMESIZE ) ;
name[ MAXNAMESIZE - 1 ] = '\0' ;
cout << "Конструктор Student "
<< name
<< endl ;
}
void addCourse( int hours , float grade )
{
cout << "Добавляем оценку для " << name << endl ;
average = ( semesterHours * average + grade ) ;
semesterHours += hours ;
average = average / semesterHours ;
}
int hours( ) { return semesterHours ; }
float gpa( ) { return average ; }
protected :
char name[ MAXNAMESIZE ] ;
int semesterHours ;
float average ;
} ;
class GraduateStudent : public Student
{
public :
GraduateStudent( char *pName , Advisor& adv , float qG = 0.0 )
: Student( pName ), advisor( adv ) , qualifierGrade(qG)
{
cout << "Конструктор GraduateStudent "
<< pName
<< endl ;
}
float qualifier( ) { return qualifierGrade ; }
protected :
Advisor advisor ;
_________________
235 стр. Глава 20. Наследование классов
float qualifierGrade ;
} ;
int main( int nNumberofArgs , char* pszArgs[ ] )
{
/* печать кириллицы, если Вы не установите программки gccrus.exe и g++rus.exe */
setlocale ( LC_ALL ,".1251" ) ;
Advisor advisor ;
/* Создание двух типов студентов */
Student llu( "Су N Sense" ) ;
GraduateStudent gs( "Matt Madox" , advisor , 1.5 ) ;
/* Добавляем им оценки */
llu.addCourse( 3 , 2.5 ) ;
gs.addCourse( 3 , 3.0 ) ;
// Выводим их
cout << "Оценка Matt = "
<< gs.qualifier( )
<< endl ;
/* Пауза для того, чтобы посмотреть на результат работы программы */
system( "PAUSE" ) ; return 0 ;
}
В этой программе продемонстрировано создание и использование двух объектов — Student и GraduateStudent. Вывод программы выглядит следующим образом.
Конструктор Student Су N Sense
Конструктор Student Matt Madox
Конструктор GraduateStudent Matt Madox
Добавляем оценку для Су N Sense
Добавляем оценку для Matt Madox
Оценка Matt = 1.5
Press any key to continue...
Класс Student определён как обычно. Определение класса GraduateStudent несколько отличается — наличием после имени класса двоеточия с последующим public Student. Тем самым класс GraduateStudent объявляется как подкласс класса Student.
«Ключевое слово public говорит о том, что может быть наследование protected, а также private — но эти вопросы лежат за пределами данной книги.»
[Советы]
Программисты любят вводить новые термины и придавать новые значения старым. Вот набор тождественных высказываний, описывающих одно и то же отношение между классами:
■■■
■ GraduateStudent — подкласс Student;
■ Student — базовый, или родительский класс для GraduateStudent;
■ GraduateStudent наследует Student;
■ GraduateStudent расширяет Student.
■■■
_________________
236 стр. Часть 4. Наследование
В качестве подкласса Student класс GraduateStudent наследует все его члены. Например, GraduateStudent имеет член name, хотя он объявлен в базовом классе. Однако подкласс может добавлять собственные члены, например, qualifierGrade.
Функция main( ) объявляет два объекта, типа Student и GraduateStudent, после чего вызывает функцию addCourse( ) для каждого из них, а потом — функцию qualifier( ), которая имеется только у подкласса.
Хотя подкласс и имеет доступ к защищённым членам базового класса, а значит, может инициализировать их, было бы хорошо, если бы базовый класс всё же конструировал сам себя. В действительности так и происходит.
Перед тем как управление получает код, стоящий за открывающей фигурной скобкой класса GraduateStudent, оно передаётся конструктору по умолчанию класса Student ( поскольку другой конструктор не был указан ). Если бы класс Student был наследником другого класса, например Person, то конструктор этого класса вызывался бы до передачи управления конструктору Student. Подобно небоскребу, объект строится, начиная с "фундаментального" уровня в соответствии со структурой наследования классов и вызывая конструкторы всех классов, составляющих данный.
Как и в случае с объектами-членами, вам может понадобиться передавать аргументы конструктору базового класса. Это делается почти так же, как и изученная ранее передача аргументов конструктору объекта-члена ( смотрите приведённый ниже пример ).
GraduateStudent( char *pName , Advisor&adv , float qG=0.0 )
:Student( pName ) , advisor( adv ) , qualifierGrade(qG)
{
/* Код конструктора */
}
В этом примере конструктор класса GraduateStudent вызывает конструктор Student, передавая ему аргумент pName. Базовый класс конструируется до любых объектов-членов, а значит, конструктор класса Student вызывается перед конструктором Advisor. И только после конструктора члена Advisor начинает работу конструктор GraduateStudent.
В случае, когда в подклассе не указан явный вызов конструктора базового класса, вызывается конструктор по умолчанию базового класса. Таким образом, в следующем коде базовый класс Pig конструируется до членов LittlePig, несмотря на то, что конструктор LittlePig не делает явного вызова конструктора Pig.
class Pig
{
public :
Pig( ) : pHouse( 0 ) { }
protected :
House* pHouse ;
} ;
class LittlePig : public Pig
{
public :
LittlePig( float volStraw , int numSticks , int numBricks )
: straw( volStraw ) , sticks( numSticks ) , bricks( numBricks )
{ }
protected :
float straw ;
int sticks ;
int bricks ;
} ;
Аналогично, автоматически вызывается копирующий конструктор базового класса.
_________________
237 стр. Глава 20. Наследование классов
Следуя правилу о том, что деструкторы вызываются в порядке, обратном вызову конструкторов, первым вызывается деструктор GraduateStudent. После того как он выполнит свою работу, управление передаётся деструктору класса Advisor, а затем деструктору Student. Если бы Student был наследником класса Person, его деструктор получил бы управление после деструктора Student.
И это логично. Блок памяти сначала преобразуется в объект Student, а уже затем конструктор для GraduateStudent превращает этого студента в аспиранта. Деструктор же просто выполняет этот процесс в обратном направлении.
Обратите внимание, что класс GraduateStudent включает в себя члены классов Student и Advisor, однако он включает их по-разному. Определяя данные-члены класса Advisor, вы знаете, что класс Student содержит внутри все данные-члены класса Advisor, но вы не можете сказать, что GraduateStudent ЯВЛЯЕТСЯ Advisor. Однако вы можете сказать, что GraduateStudent СОДЕРЖИТ Advisor. Какая разница между этим отношением и наследованием?
Используем в качестве примера автомобиль. Вы можете логически определить автомобиль как подкласс транспортных средств, а значит, он будет наследовать свойства остальных транспортных средств. С другой стороны, автомобиль содержит мотор. Если вы покупаете автомобиль, то покупаете и мотор ( если, конечно, вы не покупаете бывшую в употреблении машину там же, где я купил свою кучу металлолома ).
Если друзья пригласят вас приехать на воскресный пикник на новой машине и вы приедете на ней, никто не будет удивлён ( даже если вы явитесь на мотоцикле ), поскольку автомобиль ЯВЛЯЕТСЯ транспортным средством. Но если вы появитесь на своих двоих, неся в руках мотор, друзья решат, что вы попросту издеваетесь над ними, поскольку мотор не является транспортным средством, так как не имеет некоторых важных свойств, присущих транспортным средствам.
В аспекте программирования связь типа СОДЕРЖИТ достаточно очевидна. Разберём следующий пример:
class Vehicle
{
} ;
class Motor
{
} ;
class Car : public Vehicle
{
public :
Motor motor ;
} ;
void VehicleFn( Vehicle& v ) ;
void motorFn( Motor& m ) ;
_________________
238 стр. Часть 4. Наследование
int main( )
{
Car с ;
VehicleFn( с ) ; /* Так можно вызвать */
motorFn( c ) ; /* А так — нельзя */
motorFn( с.motor ) ; /* Нужно вот так */
return 0 ;
}
Вызов VehicleFn( с ) допустим, поскольку с ЯВЛЯЕТСЯ Vehicle. Вызов motorFn( с ) недопустим, поскольку с — не Motor, хотя он и содержит Motor. Если возникает необходимость передать функции только ту часть с, которая является мотором, это следует выразить явно: motorFn( с.motor ).
_________________
239 стр. Глава 20. Наследование классов
В этой главе...
►Когда функция не является виртуальной 246
Количество и тип аргументов функции включены в её полное или, другими словами, расширенное имя. Это позволяет создавать в одной программе функции с одним и тем же именем ( если различаются их полные имена ):
void someFn( int )
void someFn( char* )
void someFn( char* , double )
Во всех трёх случаях функции имеют одинаковое короткое имя someFn( ). Полные имена всех трёх функций различаются: someFn( int ) отличается от someFn( char* ) и т.д. С++ решает, какую именно функцию нужно вызвать, рассматривая полные имена слева направо.
«Тип возвращаемого значения не является частью полного имени функции, поэтому вы не можете иметь две функции с одинаковым расширенным именем, отличающиеся только типом возвращаемого объекта.»
[Атас!]
Итак, функции-члены могут быть перегружены. При этом помимо количества и типов аргументов расширенное имя функции-члена содержит ещё и имя класса.
С появлением наследования возникает небольшая неувязка. Что, если функция-член базового класса имеет то же имя, что и функция-член подкласса? Попробуем разобраться с простым фрагментом кода:
class Student
{
public :
float calcTuition( ) ;
} ;
class GraduateStudent : public Student
{
public :
float calcTuition( ) ;
} ;
int main( int argcs , char* pArgs[ ] )
{
Student s ;
GraduateStudent gs ;
s.calcTuition( ) ; /* Вызывает Student::calcTuition( ) */
gs.calcTuition( ) ; /* Вызывает GraduateStudent::calcTuition( ) */
return 0 ;
}
_________________
240 стр. Часть 4. Наследование
Как и в любой ситуации с перегрузкой, когда программист обращается к calcTuition( ), С++ должен решить, какая именно функция calcTuition( ) вызывается. Если две функции отличаются типами аргументов, то нет никаких проблем. Даже если аргументы одинаковы, различий в именах класса достаточно, чтобы решить, какой именно вызов нужно осуществить, а значит, в этом примере нет ничего необычного. Вызов s.calcTuition( ) обращается к Student::calcTuition( ), поскольку s локально объявлена как Student, тогда как gs.calcTuition( ) обращается к GraduateStudent::calcTuition( ).
Но что, если класс объекта не может быть точно определён на этапе компиляции? Чтобы продемонстрировать подобную ситуацию, нужно просто немного изменить приведённую выше программу:
//
/* OverloadOverride — демонстрация невозможности */
/* точного определения типа */
//
#include
#include
#include
using namespace std ;
class Student
{
public :
/* Раскомментируйте одну из двух следующих строк; одна выполняет раннее связывание calcTuition( ), а вторая — позднее */
float calcTuition( )
/* virtual float calcTuition( ) */
{
cout << "Функция Student::calcTuition" << endl ;
return 0 ;
}
} ;
class GraduateStudent : public Student
{
public :
float calcTuition( )
{
cout << "Функция GraduateStudent::calcTuition"
<< endl ;
return 0 ;
}
} ;
void fn( Student& x )
{
x.calcTuition( ) ; /* Какая функция calcTuition( ) должна быть вызвана? */
}
_________________
241 стр. Глава 21. Знакомство с виртуальными функциями-членами: настоящие ли они
int main( int nNumberofArgs , char* pszArgs[ ] )
{
setlocale ( LC_ALL , ".1251" ) ; /* печать кириллицы */
/* Передача функции объекта базового класса */
Student s ;
fn( s ) ;
/* Передача функции объекта подкласса */
GraduateStudent gs ;
fn( gs ) ;
/* Пауза для того, чтобы посмотреть на результат работы программы */
system( "PAUSE" ) ; return 0 ;
}
Данная программа генерирует следующий вывод:
Функция Student::calcTuition
Функция Student::calcTuition
Press any key to continue...
На этот раз вместо прямого вызова calcTuition( ) осуществляется вызов через промежуточную функцию fn( ). Теперь всё зависит от того, какой аргумент передаётся fn( ), поскольку х может быть как Student, так и GraduateStudent — ведь GraduateSudent ЯВЛЯЕТСЯ Student!
«Если вы этого не знали, это вовсе не говорит о том, что вы ЯВЛЯЕТЕСЬ "чайником". Это значит, что вы не читали главу 20 , "Наследование классов".»
[Помни!]
Аргумент х, передаваемый fn( ), для экономии места и времени объявлен как ссылка на объект класса Student. Если бы этот аргумент передавался по значению, С++ пришлось бы при каждом вызове fn( ) конструировать новый объект Student. В зависимости от вида класса Student и количества вызовов fn( ) в итоге это может занять много времени, тогда как при вызове fn( Student& ) или fn( Student* ) передаётся только адрес. Если вы не поняли, о чём я говорю, перечитайте главу 18, "Копирующий конструктор".
Было бы неплохо, если бы строка х.calcTuition( ) вызывала Student::calcTuition( ), когда х является объектом класса Student, и GraduateSudent::calcTuition( ), когда х является объектом класса GraduateStudent. Если бы С++ был настолько "сообразителен", это было бы действительно здорово! Почему? Об этом вы узнаете далее в главе.
Обычно компилятор уже на этапе компиляции решает, к какой именно функции обращается вызов. После того как вы щёлкаете на кнопке, которая даёт указание компилятору С++ собрать программу, компилятор должен просмотреть её и на основе используемых аргументов выбрать, какую именно перегружаемую функцию вы имели в виду.
В данном случае объявленный тип аргумента функции fn( ) не полностью описывает требования к функции. Хотя аргумент и объявлен как Student, он может оказаться также и GraduateStudent. Окончательное решение можно принять, только когда программа выполняется ( это называется "на этапе выполнения" ). И только когда функция fn( ) уже вызвана, С++ может посмотреть на тип аргумента и решить, какая именно функция-член должна вызываться: из класса Student или из GraduateStudent.
_________________
242 стр. Часть 4. Наследование
«Типы аргументов, с которыми вы сталкивались до этого времени, называются объявленными, или типами этапа компиляции. Объявленным типом аргумента х в любом случае является Student, поскольку так написано в объявлении функции fn( ). Другой, текущий, тип называется типом этапа выполнения. В случае с примером функции fn( ) типом этапа выполнения аргумента х является Student, если fn( ) вызывается с s, и GraduateStudent, когда fn( ) вызывается с gs.»
[Советы]
Способность решать на этапе выполнения, какую именно из нескольких перегружаемых функций в зависимости от текущего типа следует вызывать, называется полиморфизмом, или поздним связыванием. Чтобы подчеркнуть противоположность позднему связыванию, выбор перегружаемой функции на этапе компиляции называют ранним связыванием.
Перегрузка функции базового класса называется переопределением ( overriding ) функции базового класса. Такое новое название используется, чтобы отличать этот более сложный случай от нормальной перегрузки.
Полиморфизм является ключом ( одним из связки ), который способен открыть всю мощь объектно-ориентированного программирования. Он настолько важен, что языки, не поддерживающие полиморфизм, не имеют права называться объектно-ориентированными.
«Языки, которые поддерживают классы, но не поддерживают полиморфизм, называются объектно-основанными. К таким языкам относится, например, Ada.»
[Советы]
Без полиморфизма от наследования было бы мало толку. Позвольте привести ещё один пример, чтобы вы поняли, почему это так. Представим себе, что я написал действительно сложную программу, использующую некий класс, который называется — не будем далеко ходить за примером — Student. После нескольких месяцев разработки, кодирования и тестирования я выставляю эту программу на всеобщее обозрение, чтобы услышать восторженные отзывы и критику от своих коллег. ( Программа настолько "крута", что уже заходит речь о передаче мне контрольного пакета акций Microsoft... но не будем опережать события. )
Проходит время, и мой босс просит добавить в программу возможность работы с аспирантами, которые хотя и очень похожи, но всё-таки отличаются от обычных студентов ( правда, аспиранты думают, что они совсем не похожи на студентов! ). Мой босс не знает и не интересуется тем, что где-то глубоко в программе функция someFunction( ) вызывает функцию-член calcTuition( ) ( такая уж работа у босса — ни о чём не думать и не волноваться... ).
void someFunction( Student& s )
{
/* ...то, что эта функция должна делать... */
s.calcTuition( ) ;
/* ...функция продолжается... */
}
Если бы С++ не поддерживал позднее связывание, мне бы пришлось отредактировать функцию someFunction( ) приблизительно так, как показано ниже, и добавить её в класс GraduateStudent.
_________________
243 стр. Глава 21. Знакомство с виртуальными функциями-членами: настоящие ли они
#define STUDENT 1
#define GRADUATESTUDENT 2
void someFunction( Student& s )
{
/* ...то, что эта функция должна делать... Добавим тип члена, который будет индицировать текущий тип объекта */
switch ( s.type )
{
case STUDENT :
s.Student::calcTuition( ) ;
break ;
case GRADUATESTUDENT :
s.GraduateStudent::calcTuition( ) ;
break ;
}
/* ...функция продолжается... */
}
Мне бы пришлось добавить в класс переменную type. После этого я был бы вынужден добавить присвоения type = STUDENT к конструктору Student и type = GRADUATESTUDENT к конструктору GraduateStudent. Значение переменной type отражало бы текущий тип объекта s. Затем мне пришлось бы добавить проверяющие команды, показанные в приведённом выше фрагменте программы, везде, где вызываются переопределяемые функции.
Это не так уж и трудно, если не обращать внимания на три вещи. Во-первых, в данном примере описана только одна функция. Представьте себе, что calcTuition( ) вызывается из нескольких мест и что этой функции придётся выбирать не между двумя, а между пятью или десятью классами. Маловероятно, что я найду все места в программе, которые надо отредактировать.
Во-вторых, я должен изменить ( читай — сломать ) код, который был отлажен и работал, а местами был довольно запутан. Редактирование может занять много времени и стать довольно скучной процедурой, что обычно ослабляет моё внимание. Любое изменение может оказаться ошибочным и конфликтовать с существующим кодом. Кто знает?..
И наконец, после того как я завершу редактирование, отладку и тестирование программы, я должен буду поддерживать две её версии ( если, конечно, не перестану поддерживать исходную ). Это означает наличие двух потенциальных источников проблем в случае выявления ошибок и необходимость отдельной системы систематизации ( как вам такая тавтология? ), чтобы содержать всё это в порядке.
А теперь представьте себе, что случится, когда мой босс захочет добавить ещё один класс ( босс — он такой: на всё способен... ). Мне придётся не только повторить весь процесс сначала, а поддерживать три версии программы!
При наличии полиморфизма всё, что потребуется сделать, — это добавить новый подкласс и перекомпилировать программу. В принципе мне может понадобиться изменить сам базовый класс, но только его и только в одном месте. Изменения в коде приложения будут сводиться к минимуму.
На некотором философском уровне есть ещё более важные причины для полиморфизма. Помните, как я готовил закуски в микроволновой печи? Можно сказать, что я действовал по принципу позднего связывания. Рецепт был таким: разогрейте закуску в печи. В нём не было сказано: если печь микроволновая, сделай так, а если конвекционная — эдак. В рецепте ( читай — коде ) предполагалось, что я ( читай — тот, кто осуществляет позднее связывание ) сам решу, какой именно разогрев ( функцию-член ) выбрать, в зависимости от типа используемой печи ( отдельного экземпляра класса Oven ) или её вариаций ( подклассов ), например таких, как микроволновая печь ( Microvawe ). Так думают люди, и так же создаются языки программирования: чтобы дать людям возможность, не изменяя образа мыслей, создавать более точные модели реального мира.
_________________
244 стр. Часть 4. Наследование
Любой язык программирования может поддерживать раннее либо позднее связывание. Старые языки типа С в основном поддерживают раннее связывание. Более поздние языки, наподобие Java, поддерживают позднее связывание. С++ же поддерживает оба типа связывания.
Вас может удивить, что по умолчанию С++ использует раннее связывание. Если немного подумать, причина становится понятной. Во-первых, для достижения максимальной обратной совместимости с языком С в С++ используется такое же как и в С раннее связывание. Во-вторых, позднее связывание несколько менее эффективно и требует как выполнения дополнительного кода, так и дополнительных затрат памяти. Отцы-основатели С++ беспокоились о том, что любое изменение, которое они представят в С++ как усовершенствование его предшественника С, может стать поводом для неприятия этого языка в качестве системного языка программирования. Поэтому они сделали более эффективное раннее связывание используемым по умолчанию.
Последняя причина в том, что достаточно полезной для программиста оказывается возможность определить, будет ли переопределяться некоторая функция в будущем или нет. Этого оказалось достаточно, чтобы в С# Microsoft позволила программистам указывать, что некоторая функция будет непереопределимой ( по умолчанию все функции переопределимы ).
Чтобы сделать функцию-член полиморфной, программист на С++ должен пометить её ключевым словом virtual так, как это показано ниже.
class Student
{
public :
/* Раскомментируйте одну из двух следующих строк; одна выполняет раннее связывание calcTuition( ), а вторая — позднее */
virtual float calcTuition( )
{
cout << "Функция Student::calcTuition" << endl ;
return 0 ;
}
} ;
Ключевое слово virtual сообщает С++ о том, что calcTuition( ) является полиморфной функцией-членом. Это так называемое виртуальное объявление calcTuition( ) означает, что вызовы данной функции-члена будут связаны позже, если есть хоть какие-то сомнения по поводу типа объекта, для которого будет вызываться функция calcTuition( ) на этапе выполнения.
В приведённой ранее демонстрационной программе OverloadOverride calcTuition( ) вызывается через промежуточную функцию fn( ). Когда функции fn( ) передаётся объект базового класса, она вызывает функцию Student::calcTuition( ). Но когда функции передаётся объект подкласса, этот же вызов обращается к функции GraduateStudent::calcTuition( ).
Запуск программы приведёт к выводу на экран таких строк:
Функция Student::calcTuition
Функция GraduateStudent::calcTuition
Press any key to continue...
«Если вы уже освоились с отладчиком вашей среды С++, настоятельно рекомендую выполнить этот пример в пошаговом режиме.»
_________________
245 стр. Глава 21. Знакомство с виртуальными функциями-членами: настоящие ли они
«Достаточно объявить функцию виртуальной только в базовом классе. Виртуальность наследуется подклассами автоматически. Однако в этой книге я следую стандарту кодирования, в соответствии с которым функции объявляются виртуальными везде.»
[Советы]
«Обратитесь к программе PolymorphicNachos.срр на прилагаемом компакт-диске, чтобы лучше ознакомиться с полиморфизмом.»
[Диск]
Даже если вы считаете, что некоторая функция вызывается с использованием позднего связывания, это отнюдь не означает, что так и есть на самом деле. Если она не объявлена с теми же аргументами в подклассах, то она не будет переопределяться, независимо от того, объявили ли вы её виртуальной или нет.
В правиле об идентичности объявления есть только одно исключение, которое состоит в том, что если функция-член базового класса возвращает указатель или ссылку на объект базового класса, то переопределяемая функция-член может возвращать указатель или ссылку на объект подкласса. Другими словами, приведённая ниже программа допустима.
class Base
{
public :
/* Возвращает копию текущего объекта */
Base* makeACopy( )
{
/* ...делает всё, что нужно для создания копии */
}
} ;
class Subclass : public Base
{
public :
/* Возвращает копию текущего объекта */
Subclass* makeACopy( )
{
/* ...Делает всё, что нужно для создания копии */
}
} ;
void fn( BaseClass& bс )
{
BaseClass* pCopy = bс.makeACopy( ) ;
/* Функция продолжается... */
}
С практической точки зрения всё естественно: функция копирования makeCopy( ) должна возвращать указатель на объект типа Subclass, даже если она переопределяет Base::makeCopy( ).
_________________
246 стр. Часть 4. Наследование
При использовании виртуальных функций не следует забывать о некоторых вещах. Во-первых, статические функции-члены не могут быть объявлены виртуальными. Поскольку статические функции-члены не вызываются с объектом, никакого объекта этапа выполнения не может быть, а значит, нет и его типа.
Во-вторых, при указании имени класса в вызове функция будет компилироваться с использованием раннего связывания независимо от того, объявлена она виртуальной или нет.
Например, приведённый ниже вызов обращается к Base::fn( ), поскольку так указал программист, независимо от того, объявлена fn( ) виртуальной или нет.
void test( Base& b )
{
b.base::fn( ) ;
/* Этот вызов не использует позднего связывания */
}
Кроме того, виртуальная функция не может быть встроенной. Чтобы подставить функцию на место её вызова, компилятор должен знать её на этапе компиляции. Таким образом, независимо от способа описания виртуальные функции-члены рассматриваются как не встроенные.
И наконец, конструкторы не могут быть виртуальными, поскольку во время работы конструктора не существует завершённого объекта какого-либо определённого типа. В момент вызова конструктора память, выделенная для объекта, является просто аморфной массой. И только после окончания работы конструктора объект становится экземпляром класса в полном смысле этого слова.
В отличие от конструктора, деструктор может быть объявлен виртуальным. Более того, если он не объявлен виртуальным, вы рискуете столкнуться с неправильной ликвидацией объекта, как, например, в следующей ситуации:
class Base
{
public :
~Base( ) ;
} ;
class SubClass : public Base
{
public :
~SubClass( ) ;
} ;
void finishWithObject( Base* pHeapObject )
{
delete pHeapObject ; /* Здесь вызывается ~Base( ) независимо от типа указателя pHeapObject */
}
Если указатель, передаваемый функции finishWithObject( ), на самом деле указывает на объект SubСlass, деструктор Subclass всё равно вызван не будет: поскольку он не был объявлен виртуальным, используется раннее связывание. Однако, если объявить деструктор виртуальным, проблема будет решена.
А если вы не хотите объявлять деструктор виртуальным? Тому может быть только одна причина: виртуальные функции несколько увеличивают размер объекта. Когда программист определяет первую виртуальную функцию в классе, С++ прибавляет к классу дополнительный скрытый указатель — именно один указатель на класс, а не для каждой виртуальной функции.
_________________
247 стр. Глава 21. Знакомство с виртуальными функциями-членами: настоящие ли они
Класс, который не содержит виртуальных функций и не наследует никаких виртуальных функций от базовых классов, не будет содержать этого указателя. Однако один указатель не такая уж большая цена безопасной работы программы. Цена становится значимой, если
■■■
■ класс не содержит много данных, так что даже один указатель существенно увеличивает его размер;
■ вы намерены создать большое количество объектов, так что один указатель, умноженный на количество объектов, даст существенный перерасход памяти.
■■■
Если выполняются два перечисленных условия, а в классе нет виртуальных функций — это единственное основание не делать деструктор виртуальным.
«Лучше всегда объявлять деструкторы виртуальными, даже если ваш класс не наследуется ( пока не наследуется! ): ведь никогда не известно, в какой момент появится некто ( может, это будете вы сами ), желающий воспользоваться вашим классом как базовым для своего собственного класса. Если вы не объявили деструктор виртуальным, обязательно документируйте это!»
[Атас!]
_________________
248 стр. Часть 4. Наследование
В этой главе...
►Разложение 249
►Реализация абстрактных классов 253
►Разделение исходного кода С++ 259
Концепция наследования помогает в достижении многих целей; например, благодаря ему я плачу за обучение моего сына. Оно помогает избежать повторения кода и сократить время, затрачиваемое на написание программ. Благодаря наследованию можно повторно использовать уже существующий код в новых программах, переопределяя функции.
Главное преимущество наследования — возможность указывать тип взаимосвязи между классами. Это так называемая взаимосвязь типа ЯВЛЯЕТСЯ: микроволновая печь ЯВЛЯЕТСЯ печью и т.д.
Разложение — это прекрасный способ создания правильных связей. К примеру, связь микроволновой печи с конвекционной печью кажется естественной. Утверждение же о том, что микроволновая печь является особым типом тостера, скорее всего, вас несколько насторожит. Конечно, оба эти прибора нагревают, оба используют электричество и оба находятся на кухне, но на этом сходство заканчивается — микроволновая печь не готовит тосты.
Процедура определения классов, свойственных данной проблеме, и задания корректных связей между этими классами известна под названием разложение ( factoring ) ( это слово относится к арифметике, с которой вы мучились в средней школе; помните, как вы занимались разложением числа на простые множители: 12 равно 2, умноженное на 2 и на 3... ).
Чтобы увидеть, как использовать наследование для упрощения ваших программ, рассмотрим простейшее банковское приложение.
«Предположим, что нам надо написать простейшую банковскую программу. ( Описание этой программы имеется на прилагаемом компакт-диске. )»
[Диск]
Я мог бы до посинения рассказывать об этих классах, однако, к счастью, объектно-ориентированные программисты придумали довольно наглядный и краткий путь описания классов. Классы Checking и Savings показаны на рис. 22.1.
Для того чтобы правильно понять этот рисунок, необходимо знать несколько правил.
■■■
■ Большой прямоугольник — это класс. Имя класса написано сверху.
■ Имена в меньших прямоугольниках — это функции-члены.
_________________
249 стр. Глава 22. Разложение классов
Рис. 22.1. Независимые классы Checking и Savings
■ Имена не в прямоугольниках — это данные-члены.
■ Имена, которые выступают за пределы прямоугольника, ограничивающего класс, являются открытыми; к этим членам могут обращаться функции, не являющиеся членами класса или его наследников. Члены, которые находятся полностью внутри прямоугольника, недоступны снаружи класса.
■ Толстая стрелка обозначает связь типа ЯВЛЯЕТСЯ.
■ Тонкая стрелка обозначает связь типа СОДЕРЖИТ.
■■■
Автомобиль ЯВЛЯЕТСЯ транспортным средством и при этом СОДЕРЖИТ мотор.
На рис. 22.1 вы можете увидеть, что классы Checking и Savings имеют много общего. Например, оба класса включают функции-члены withdrawal( ) и deposit( ). Поскольку эти классы не идентичны, они, конечно же, должны оставаться раздельными ( в реальном банковском приложении эти два класса отличались бы гораздо существеннее ). Однако мы должны найти способ избежать дублирования.
Можно сделать так, чтобы один из этих классов наследовал другой. Класс Savings имеет больше членов, чем Checking, так что мы могли бы унаследовать Savings от Checking. Такой путь реализации этих классов приведён на рис. 22.2. Класс Savings наследует все члены класса Checking. Кроме того, в классе добавлен член noWithdrawal и переопределена функция withdrawal( ). Эта функция переопределена, поскольку правила снятия денег со сберегательного счёта отличаются от правил снятия с чекового счёта ( хотя меня эти правила вообще не касаются, поскольку у меня нет денег, которые можно было бы снять со счёта ).
Хотя наследование Savings от Checking и сберегает наш труд, нас оно не очень удовлетворяет. Главная проблема состоит в том, что оно искажает истинное положение вещей. При таком использовании наследования подразумевается, что счёт Savings является специальным случаем счёта Checking.
"Ну и что? — скажете вы. — Такое наследование работает и сохраняет нам силы и время". Это, конечно, так, но мои предупреждения — это не просто сотрясание воздуха. Такие искажения запутывают программиста уже и сейчас, но ещё больше будут мешать в дальнейшем. Однажды программист, не знакомый с нашими "приёмчиками", будет читать нашу программу, пытаясь понять, что же она делает. Вводящие в заблуждение представления очень трудны для понимания и ведения программы.
_________________
250 стр. Часть 4. Наследование
Рис. 22.2. Класс Savings реализован как подкласс checking
Кроме того, такие искажения могут привести к проблемам в будущем. Например, представьте себе, что банк изменит свою политику относительно чековых счетов. Скажем, он решит взимать гонорар за обслуживание чековых счетов только в том случае, если минимальный баланс упадёт ниже некоторого значения в течение месяца.
Такое изменение политики банка можно легко отразить в классе Checking. Все, что нужно сделать, — это добавить новый член в класс Checking, чтобы следить за минимальным балансом в течение месяца. Назовём его minimumBalance.
Однако теперь возникает проблема. Если Savings наследует Checking, значит, Savings тоже получает этот член. При этом он не используется, поскольку в сберегательных счетах минимальный баланс не нужен. Так что дополнительный член просто присутствует в классе. Итак, каждый объект чекового счёта имеет дополнительный член minimumBalance. Один дополнительный член — это не так уж и много, но он вносит свою лепту в общую неразбериху.
Такие изменения имеют свойство накапливаться. Сегодня это один член, а завтра — изменённая функция-член. В результате объекты класса Savings будут содержать множество дополнительных данных, которые нужны исключительно в классе Checking. Если вы будете невнимательны, изменения в классе Checking могут перейти к классу Savings и привести к его некорректной работе.
Далее банк решил изменить правила работы с чековыми счетами. Для этого вам требуется изменить некоторые функции в Checking. Эти изменения автоматически перейдут в подклассы. Предположим, например, что банк решил начислять дополнительные проценты по чековым вкладам ( ну, бывают же чудеса... ) — но главное чудо будет в том, что при нашей системе наследования эти же проценты будут начисляться и на сберегательных счетах.
Как же этого избежать? Если поменять местами Checking и Savings, проблема не исчезнет. Нужен некий третий класс ( назовём его Account ), который будет воплощать в себе всё то общее, что есть у Checking и Savings. Такая связь приведена на рис. 22.3.
Каким образом создание нового класса Account решит наши проблемы? Во-первых, такой класс сделает более аккуратным описание реального мира ( чем бы он ни являлся ). В нашей концепции мира ( по крайней мере, в моей ) действительно есть нечто, что можно назвать счётом.
_________________
251 стр. Глава 22. Разложение классов
Сберегательные и чековые счета являются частным случаем этой более фундаментальной концепции.
Кроме того, класс Savings отмежёвывается от изменений в классе Checking ( и наоборот ). Если банк решит провести фундаментальные изменения во всех счетах, можно просто изменить класс Account, и все подклассы автоматически унаследуют эти изменения. Но если банк изменит политику только для чековых счетов, можно просто модифицировать класс Checking, не изменяя при этом класс Savings.
Такая процедура отбора общих свойств похожих классов и называется разложением. Этот процесс очень важен в объектно-ориентированных языках по причинам, которые были приведены выше, а также потому, что разложение помогает избавиться от избыточности. Позвольте мне повториться: избыточность — это не просто плохо, это очень плохо...
Рис. 22.3. Классы checking и Savings, базирующиеся на классе Account
«Разложение будет обоснованным только в том случае, когда взаимосвязь, представляемая наследованием, соответствует реальности. Выделение общих свойств класса Mouse и Joystick и разложение их на "множители" вполне допустимо. И мышь и джойстик являются аппаратными устройствами позиционирования. Но выделение общих свойств классов Mouse и Display ничем не обосновано.»
[Атас!]
Разложение может давать ( и обычно даёт ) результат на нескольких уровнях абстракции. Например, программа, написанная для более "продвинутого" банка, может иметь структуру классов, показанную на рис. 22.4.
Из этого рисунка видно, что между классами Checking и Savings и более общим классом Account вставлен ещё один класс. Он называется Conventional и объединяет в себе особенности обычных счетов. Другие типы счетов, например счета ценных бумаг и биржевые счета, также объявляются как отдельные классы.
Такая многослойная структура классов весьма распространена и даже желательна ( пока отношения, которые она представляет, отражают реальность. Однако не забывайте, что для любого заданного набора классов не существует одной единственно правильной иерархии классов ).
_________________
252 стр. Часть 4. Наследование
Рис. 22.4. Развитая структура банковских счетов
Представим, что банк позволяет держателям счетов удаленно обращаться к чековым счетам и счетам ценных бумаг. Снимать же деньги с других типов счетов можно только в банке. Хотя структура классов, приведённая на рис. 22.4, выглядит естественной, в данных условиях более приемлема другая структура ( рис. 22.5 ). Программист должен решить, какая структура классов лучше всего подходит к данным условиям, и стремиться к наиболее ясному и естественному представлению.
Рис. 22.5. Альтернативная иерархия классов
Такое интеллектуальное упражнение, как разложение, поднимает ещё одну проблему. Вернёмся к классам банковских счетов ещё раз, а именно к общему базовому классу Account. На минуту задумайтесь над тем, как вы будете определять различные функции класса Account.
Большинство функций-членов класса Account не составят проблем, поскольку оба типа счетов реализуют их одинаково. Однако функция Account.withdrawal( ) отличается в зависимости от типа счёта. Правила снятия со сберегательного и чекового счетов различны. Мы вынуждены реализовывать Savings::withdrawal( ) не так, как Checking::withdrawal( ). Но как реализовать функцию Account::withdrawal( ) ?
Попросим банковского служащего помочь нам. Я так представляю себе эту беседу:
"Каковы правила снятия денег со счёта?" — спросите вы с надеждой.
"Какого именно счёта, сберегательного или чекового?" — ответит он вопросом на вопрос.
"Со счёта, — скажете вы, — просто со счёта!"
_________________
253 стр. Глава 22. Разложение классов
Пустой взгляд в ответ...
Проблема в том, что такой вопрос не имеет смысла. Нет такой вещи, как "просто счёт". Все счета ( в данном примере ) должны быть чековыми или сберегательными. Концепция счёта — это абстракция, с помощью которой мы объединяем общие свойства для конкретных счетов. Это незавершённая концепция, поскольку в ней отсутствует такое важное свойство, как функции withdrawal( ) ( если вы углубитесь в детали, то найдёте и другие свойства, которых не хватает "просто счёту" ).
Абстрактный класс — это тот класс, который реализуется только в подклассе. Конкретный — тот, который не является абстрактным.
Чтобы объяснить, что я имею в виду, позвольте позаимствовать пример из мира животных. Наблюдая разные особи теплокровных и живородящих, вы можете заключить, что они все укладываются в концепцию под названием "млекопитающие". Вы можете выделить такие классы млекопитающих, как собачьи, кошачьи и приматы. Однако невозможно найти где-либо на земле просто млекопитающее. Другими словами, млекопитающие не могут содержать особь под названием "млекопитающее". Млекопитающее — это концепция высокого уровня, которую создал человек, и экземпляров-млекопитающих не существует.
Обратите внимание, что утверждать это с уверенностью я могу только по истечении некоторого времени. Ученые постоянно открывают новые виды животных. Проблема в том, что каждое существо обладает свойствами, которых не имеют другие; однако вполне вероятно, что в будущем кто-то найдёт такое свойство у других существ.
Отражая эту ситуацию, С++ даёт возможность оставлять абстрактные классы незавершёнными.
Абстрактный класс — это класс с одной или несколькими чисто виртуальными функциями. Прекрасно, это всё разъясняет...
Ну хорошо, чисто виртуальная функция — это функция-член без тела функции ( которого нет, например, потому, что никто не знает, как реализовать это самое тело ).
Бессмысленно спрашивать о том, каким должно быть тело функции withdrawal( ) в классе Account. Хотя, конечно, сама концепция снятия денег со счёта имеет смысл. Программист на С++ может написать функцию withdrawal( ), которая будет отражать концепцию снятия денег со счёта, но при этом данная функция не будет иметь тела, поскольку мы не знаем, как её реализовать. Такая функция называется чисто виртуальной[ 17 ] ( не спрашивайте меня, откуда взялось это название ).
Синтаксис объявления чисто виртуальной функции показан в приведённом ниже классе Account.
/* Account — это абстрактный класс */
class Account
{
protected :
Account( Account& с ) ;
public :
Account( unsigned accNo , float initialBalance = 0.0F ) ;
/* Функции доступа */
unsigned int accountNo( ) ;
float acntBalance( ) ;
static int noAccounts( ) ;
static Account *first( ) ;
Account *next( ) ;
/* Функции транзакций */
void deposit( ) ;
/* Приведённая ниже функция является чисто виртуальной */
virtual void withdrawal( float amount ) = 0 ;
protected :
/* Если хранить счета в связанном списке, не будет ограничения на их количество */
static Account *pFirst ;
Account *pNext ;
static int count ; /* Количество счетов */
unsigned accountNumber ;
float balance ;
} ;
______________
17Вообще говоря, чисто виртуальная функция может иметь тело, но обсуждение этого вопроса выходит за рамки данной книги. — Прим. ред.
_________________
254 стр. Часть 4. Наследование
Наличие после объявления функции withdrawal( ) символов = 0 показывает, что программист не намеревается в данный момент определять эту функцию. Такое объявление просто занимает место для тела функции, которое позже будет реализовано в подклассах. От подклассов класса Account ожидается, что они переопределят эту функцию более конкретно.
«Я считаю это объяснение глупым, и мне оно нравится не более чем вам, так что просто выучите и живите с ним. Для этого объяснения есть причина, если не оправдание. Каждая виртуальная функция должна иметь свою ячейку в специальной таблице, в которой содержится адрес функции. Так вот: ячейка для чисто виртуальной функции содержит нуль.»
Абстрактный класс не может быть реализован; другими словами, вы не можете создать объект абстрактного класса. Например, приведённое ниже объявление некорректно.
void fn( )
{
Account acnt( 1234, 100.00 ) ; /* Это некорректно */
acnt.withdrawal( 50 ) ; /* Куда, по-вашему, должен обращаться этот вызов? */
}
Если бы такое объявление было разрешено, конечный объект оказался бы незавершённым, поскольку был бы лишён некоторых возможностей. Например, что бы выполнял приведённый в этом же объявлении вызов? Помните, функции Account::withdrawal( ) не существует.
Абстрактные классы служат базой для других классов. Account содержит универсальные свойства для всех банковских счетов. Вы можете создать другие типы банковских счетов, наследуя класс Account, но сам этот класс не может быть реализован.
Подкласс абстрактного класса остаётся абстрактным, пока в нём не переопределены все чисто виртуальные функции. Класс Savings не является абстрактным, поскольку переопределяет чисто виртуальную функцию withdrawal( ) совершенно реальной. Объект класса Savings отлично знает, как реализовать функцию withdrawal( ) и куда обращаться при её вызове. То же касается и класса Checking: он не виртуальный, поскольку withdrawal( ) переопределяет чисто виртуальную функцию, определённую ранее в базовом классе.
_________________
255 стр. Глава 22. Разложение классов
Подкласс абстрактного класса, конечно, может оставаться абстрактным. Разберёмся с приведёнными ниже классами.
class Display
{
public :
virtual void initialize( ) = 0 ;
virtual void write( char *pString ) = 0 ;
} ;
class SVGA : public Display
{
/* Сделаем обе функции-члена "реальными" */
virtual void initialize( ) ;
virtual void write( char *pString ) ;
} ;
class HWVGA : public Display
{
/* Переопределим только одну функцию */
virtual void write( char *pString ) ;
} ;
class ThreedVGA : public HWVGA
{
virtual void initialize( ) ;
} ;
void fn( )
{
SVGA mc ;
VGA vga ;
/* Всё остальное */
}
Класс Display, описывающий дисплеи персонального компьютера, содержит две чисто виртуальные функции: initialize( ) и write( ). Вы не можете ввести эти функции в общем виде. Разные типы видеоадаптеров инициализируются и осуществляют вывод по-разному.
Один из подклассов — SVGA — не абстрактный. Это отдельный тип видеоадаптера, и программист точно знает, как его реализовать. Таким образом, класс SVGA переопределяет обе функции — initialize( ) и write( ) — именно так, как необходимо для данного адаптера.
Ещё один подкласс — HWVGA . Программисту известно, как программировать ускоренный VGA-адаптер. Поэтому между общим классом Display и его частным случаем, ThreedVGA, который представляет собой специальный тип карт 3-D, находится ещё один уровень абстракции.
В нашем обсуждении предположим, что запись во все аппаратно ускоренные карты VGA происходит одинаково ( это не соответствует истине, но представим себе, что это так ). Чтобы правильно выразить общее свойство записи, вводится класс HWVGA, реализующий функцию write( ) ( и другие общие для HWVGA свойства ).
_________________
256 стр. Часть 4. Наследование
При этом функция initialize( ) не переопределяется, поскольку для разных типов карт HWVGA она реализуется по-разному.
Несмотря на то что функция write( ) переопределена в классе HWVGA, он всё равно остаётся абстрактным, так как функция initialize( ) всё ещё не переопределена.
Поскольку ThreedVGA наследуется от HWVGA, он должен переопределить только одну функцию, initialize( ), для того чтобы окончательно определить адаптер дисплея. Таким образом, функция fn( ) может свободно реализовать и использовать объект класса ThreedVGA.
«Замещение нормальной функцией последней чисто виртуальной функции делает класс завершённым ( т.е. неабстрактным ). Только неабстрактные классы могут быть реализованы в виде объектов.»
[Помни!]
Поскольку вы не можете реализовать абстрактный класс, упоминание о возможности создавать указатели на абстрактные классы звучит несколько странно. Однако если вспомнить о полиморфизме, то станет ясно, что это не так уж глупо, как кажется поначалу. Рассмотрим следующий фрагмент кода:
void fn( Account *pAccount ) ; /* Это допустимо */
void otherFn( )
{
Savings s ;
Checking c ;
/* Savings ЯВЛЯЕТСЯ Account */
fn( &s ) ;
/* Checking — тоже */
fn( &c ) ;
}
В этом примере pAccount объявлен как указатель на Account. Разумеется, при вызове функции ей будет передаваться адрес какого-то объекта неабстрактного класса, например Checking или Savings.
Все объекты, полученные функцией fn( ), будут объектами либо класса Checking, либо Savings ( или другого неабстрактного подкласса Account ). Можно с уверенностью заявить, что вы никогда не передадите этой функции объект класса Account, поскольку никогда не сможете создать объект этого класса.
Если нельзя определить функцию withdrawal( ), почему бы просто не опустить её? Почему бы не объявить её в классах Savings и Checking, где она может быть определена, оставив в покое класс Account? Во многих объектно-ориентированных языках вы могли бы именно так и сделать. Но С++ предпочитает иметь возможность убедиться в вашем понимании того, что вы делаете.
«Не забывайте, что объявление функции — это указание полного имени функции, включающего её аргументы. Определение же функции включает в себя и код, который будет выполняться в результате вызова этой функции.»
[Помни!]
_________________
257 стр. Глава 22. Разложение классов
Чтобы продемонстрировать суть сказанного, можно внести следующие незначительные изменения в класс Account:
class Account
{
/* То же, что и раньше, но нет функции withdrawal( ) */
} ;
class Savings : public Account
{
public :
virtual void withdrawal( float amnt ) ;
} ;
void fn( Account *pAcс )
{
/* снять некоторую сумму */
pAcc -> withdrawal( 100.00f ) ;
/* Этот вызов недопустим, поскольку withdrawal( )не является членом класса Account */
}
int main( )
{
Savings s ; /* Открыть счёт */
fn( &s ) ;
/* Продолжение программы */
}
Представьте себе, что вы открываете сберегательный счёт s. Затем вы передаёте адрес этого счёта функции fn( ), которая пытается выполнить функцию withdrawal( ). Однако, поскольку функция withdrawal( ) не член класса Account, компилятор сгенерирует сообщение об ошибке.
Взгляните, как чисто виртуальная функция помогает решить эту проблему. Ниже представлена та же ситуация с абстрактным классом Account:
class Account
{
/* Почти то же, что и в предыдущей программе, однако функция withdrawal( ) определена */
virtual void withdrawal( float amnt ) = 0 ;
} ;
class Savings : public Account
{
public :
virtual void withdrawal( float amnt ) ;
} ;
void fn( Account *pAcc )
{
/* Снять некоторую сумму. Теперь этот код будет работать */
рАсс -> withdrawal( 100.00f ) ;
}
int main( )
{
Savings s ; /* Открыть счёт */
fn( &s ) ;
/* Продолжение программы */
}
_________________
258 стр. Часть 4. Наследование
Ситуация та же, но теперь класс Account содержит функцию-член withdrawal( ). Поэтому, когда компилятор проверяет, определена ли функция pAcc -> withdrawal( ), он видит ожидаемое определение Account::withdrawal( ). Компилятор счастлив. Вы счастливы. А значит, и я тоже счастлив. ( Честно говоря, для того чтобы сделать меня счастливым, достаточно футбола и холодного пива. )
Чисто виртуальная функция занимает место в базовом классе для функции с тем, чтобы позже быть переопределённой в подклассе, который будет знать, как её реализовать. Если место не будет занято в базовом классе, не будет и переопределения.
Разделение задачи имеет физическую сторону. Разделённые классы, представляющие разные концепции, должны быть разнесены по своим собственным "пространствам".
Программист может разделить единую программу на отдельные файлы, известные как модули. Эти отдельные исходные файлы компилируются раздельно, а затем объединяются в одну программу в процессе компоновки. Модули могут быть выделены в отдельные группы, известные как пространства имён ( namespaces ).
«Процесс объединения раздельно скомпилированных модулей в единый выполнимый файл называется компоновкой, или связыванием ( linking ) — линкованием.»
Имеется ряд причин для разделения программы на несколько модулей. Во-первых, разделение программы на модули приводит к более высокой степени инкапсуляции.
«Инкапсуляция представляет собой одно из преимуществ объектно-ориентированного программирования.»
[Помни!]
Во-вторых, гораздо проще разобраться в программе ( а следовательно, написать её и отладить ), которая состоит из нескольких тщательно разработанных модулей, чем в одном файле, переполненном классами и функциями.
Следующая причина заключается в возможности повторного использования. Очень сложно отследить отдельный класс, используемый разными программами, если в каждой из программ используется своя копия этого класса. Гораздо проще обстоят дела, если всеми программами используется единственный модуль с данным классом.
И наконец, вопрос времени. Компилятору наподобие Visual С++ .NET или Dev-C++ не требуется много времени, чтобы скомпилировать примеры из этой книги. Однако серьёзные коммерческие программы состоят из миллионов строк кода, и полная компиляция и сборка таких программ может потребовать больше суток машинного времени. Вряд ли программист сможет нормально работать, если после внесения любого изменения ему потребуются сутки на сборку приложения. Гораздо быстрее оказывается перекомпилировать только один файл с внесёнными изменениями, после чего быстро скомпоновать приложение из уже скомпилированных модулей.
_________________
259 стр. Глава 22. Разложение классов
Отдельные пространства имён предоставляют ещё один уровень инкапсуляции. Пространство имён должно состоять из набора модулей, которые обеспечивают однотипные возможности. Например, все математические функции имеет смысл собрать в едином пространстве имён Math.
Рассмотрим простую программу SeparateModules, которая состоит из модулей, содержащих класс Student, его подкласс GraduateStudent и модуль с функцией main( ) для тестирования этих классов.
Начнём с логического разделения программы SeparateModules. Заметим, что Student — самодостаточная сущность. Класс Student не зависит ни от каких других функций ( не считая библиотечных функций С++ ). Таким образом, имеет смысл поместить класс Student в отдельный модуль. Поскольку этот класс будет использоваться в разных местах, разобьем его на заголовочный файл с объявлением класса Student.h и файл реализации Student.срр. В идеале заголовочный файл должен содержать ровно один класс, что позволит программе включать только необходимые заголовочные файлы.
«Исторически все заголовочные файлы имеют расширение .h, но эта традиция изменена текущим стандартом С++. Теперь системные заголовочные файлы не имеют вообще никакого расширения. Тем не менее многие программисты продолжают использовать расширение .h, которое позволяет сразу отделить заголовочные файлы от файлов с исходными текстами.»
[Советы]
Полученный в результате файл Student.h выглядит следующим образом.
/* Student — базовый класс */
#ifndef _STUDENT_
#define _STUDENT_
namespace Schools
{
class Student
{
public :
Student( char* pszName , int nID ) ;
virtual char* display( ) ;
protected :
/* Имя студента */
char* pszName ;
int nID ;
} ;
}
#endif
#ifndef представляет собой директиву препроцессора, такую же, как и, например, директива #include. Данная директива гласит, что включать следующие за ней строки следует только в том случае, когда её аргумент _STUDENT_ не определён. При первом включении файла _STUDENT_ не определён, но тут же определяется директивой #define_STUDENT_. Это приводит к тому, что включение файла Student.h в программу будет выполнено только один раз, независимо от того, сколько директив #include встретится в программе.
_________________
260 стр. Часть 4. Наследование
Следующая особенность программы состоит в том, что класс Student определён в пространстве имён Schools.
Пространство имён представляет собой множество тесно связанных классов, в чём-то логически подобных друг другу. В нашем случае я хочу поместить все создаваемые классы, представляющие студентов, аспирантов и т.п. в одно пространство имён Schools.
Классы, составляющие пространство имён Schools, выглядят как члены одной семьи. Один класс из пространства имён может обращаться к другому классу из этого же пространства имён непосредственно, в то время как внешние классы должны явно указывать при обращении пространство имён.
«Ещё одной причиной для использования пространств имён служат так называемые "коллизии имён", которых надо избегать. Например, класс Grade из пространства имён Schools никак не влияет на возможность использования этого же имени для класса в пространстве имён FoodProduction.»
[Советы]
Реализация класса Student помещена мною в файл Student.срр.
/* Student — реализация методов класса Student */
#include
#include
#include
#include
#include "student.h"
namespace Schools
{
Student::Student( char* pszNameArg , int nIDArg )
: nID( nIDArg )
{
pszName = new char[ strlen( pszNameArg ) + 1 ] ;
strcpy( pszName , pszNameArg ) ;
}
/* display — возвращает описание студента */
char* Student::display( )
{
/* Копируем имя студента в блок памяти в куче, который возвращается вызывающей функции */
char* pReturn = new char[ strlen( pszName ) + 1 ] ;
strcpy( pReturn , pszName ) ;
return pReturn ;
}
}
Конструктор Student копирует имя и идентификатор студента, переданные ему в качестве аргументов. Виртуальная функция display( ) возвращает строку с описанием объекта Student.
Компиляция файла Student.срр даёт промежуточный файл, который затем может быть быстро объединён с другими файлами в завершённую выполнимую программу.
_________________
261 стр. Глава 22. Разложение классов
«По историческим причинам в большинстве сред С++ этот промежуточный файл имеет расширение .obj или .о ( "объектный файл" ).»
Следующий модуль, представляющийся квазинезависимым, — GraduateStudent. Он может быть помещён в файл Student.срр, однако ряд программ могут работать только со студентами, даже не подозревая о наличии аспирантов.
Класс GraduateStudent сделан мною максимально простым. Вот как выглядит заголовочный файл.
/* GraduateStudent — специальный тип Student */
#ifndef _GRADUATE_STUDENT_
#define _GRADUATE_STUDENT_
#include "student.h"
namespace Schools
{
class GraduateStudent : public Student
{
public :
/* Тривиальный конструктор */
GraduateStudent( char* pszName , int nID )
: Student( pszName , nID ){ }
/* Демонстрация виртуальной функции */
virtual char* display( ) ;
} ;
}
#endif
Обратите внимание, что файл GraduateStudent.h включает файл Student.h. Это связано с тем, что класс GraduateStudent зависит от определения класса Student. Файл с исходным кодом содержит реализацию метода display( ).
/* GraduateStudent — специальный тип Student */
#include
#include
#include
#include
#include "graduateStudent.h"
namespace Schools
{
char* GraduateStudent::display( )
{
/* Описание студента */
char* pFirst = Student::display( ) ;
/* Добавляем этот текст */
char* pSecond = "-G" ;
/* Выделяем память для новой строки и создаём её */
char* pName = new char[ strlen( pFirst ) +
strlen( pSecond ) + 1 ] ;
strcpy( pName , pFirst ) ;
strcat( pName , pSecond ) ;
/* Освобождаем память, которую вернул вызов Student::display( ) */
delete pFirst ;
return pName ;
}
}
_________________
262 стр. Часть 4. Наследование
Функция display( ) из класса GraduateStudent добавляет "-G" к строке, возвращаемой функцией display( ) из класса Student. Она начинает свою работу с того, что выделяет память из кучи для новой строки.
«Никогда не надо полагаться на то, что в исходном буфере имеется достаточно места для дополнительных символов, дописываемых в конец строки.»
[Атас!]
Программа копирует в новый буфер полученную от функции display( ) из класса Student строку, добавляет к ней "-G" и освобождает память, которую занимала исходная строка.
«Забыв освободить память, выделенную в куче, вы получите эффект, именуемый утечкой памяти. Программа с утечкой памяти поначалу работает вполне корректно, но со временем всё большее и большее количество памяти оказывается потерянным, что может привести к неработоспособности программы из-за отсутствия памяти. Обычно утечки памяти очень трудно обнаружить.»
[Атас!]
Два класса — Student и GraduateStudent — разнесены по отдельным исходным файлам и размещены в пространстве имён Schools. Я написал простенькую программу, использующую оба описанных класса.
//
/* SeparatedMain — демонстрационное приложение, */
/* разделённое на части */
//
#include
#include
#include
#include
#include "graduateStudent.h"
#include "student.h"
using namespace std ;
/* using namespace Schools ; */
using Schools::GraduateStudent ;
int main( int nArgc , char* pszArgs[ ] )
{
Schools::Student s( "Sophie Moore" , 1234 ) ;
cout << "Student = " << s.display( ) << endl ;
GraduateStudent gs( "Greg U. Waite" , 5678 ) ;
cout << "Student = " << gs.display( ) << endl ;
/* Пауза для того, чтобы посмотреть на результат работы программы */
system( "PAUSE" ) ; return 0 ;
}
_________________
263 стр. Глава 22. Разложение классов
Приложение включает заголовочные файлы Student.h и GraduateStudent.h, что даёт ему доступ к обоим рассмотренным классам.
Вы можете возразить, что включение GraduateStudent.h автоматически приводит к включению Student.h, однако не стоит полагаться на это — если вы хотите использовать какой-то класс, лучше включить соответствующий заголовочный файл независимо от других. Использование описанной ранее конструкции с #ifndef позволит избежать повторного включения заголовочного файла.
Заметим, что представленный модуль не является частью пространства имён Schools, так что при обращении функции main( ) к классу Student С++ не знает, где именно искать этот класс.
В связи с этим при обращении к классу следует использовать его полное имя, дабы избежать возможных неоднозначностей — т.е. имя, состоящее из имени пространства и имени класса: Schools::Student. Другой способ заключается в указании в начале модуля, какое именно пространство имён подразумевается программистом — так, инструкция
using Schools::GraduateStudent ;
говорит о том, что далее все упоминания GraduateStudent относятся к пространству имён Schools.
Программист может получить такой же доступ ко всем членам пространства имён сразу, воспользовавшись командой
using namespace Schools ;
Вот версия функции main( ), которая использует эту команду и работает аналогично предыдущей функции main( ).
using namespace Schools ;
int main( int nArgc , char* pszArgs[ ] )
{
Schools::Student s( "Sophie Moore" , 1234 ) ;
cout << "Student = " << s.display( ) << endl ;
GraduateStudent gs( "Greg U. Waite" , 5678 ) ;
cout << "Student = " << gs.display( ) << endl ;
/* Пауза для того, чтобы посмотреть на результат работы программы */
system( "PAUSE" ) ; return 0 ;
}
«Использование в данной книге инструкции using namespace std ; связано с тем, что модули, составляющие стандартную библиотеку С++, входят в пространство имён std.»
[Советы]
Полный ожиданий, я открываю файл SeparatedMain.срр в компиляторе и запускаю сборку приложения. Модуль компилируется без ошибок, но компоновка не выполняется. С++ не знает, что за класс Student. Итак, нам надо каким-то образом сообщить компилятору, что кроме SeparatedMain.срр ему нужно использовать файлы GraduateStudent.срр и Student.срр. В большинстве сред С++, включая Dev-C++ и Visual С++ .NET, это делается при помощи файла проекта.
Dev-C++ и Visual С++ .NET имеют собственные форматы файла проекта. Здесь мы рассмотрим, как работать с файлом проекта в Dev-C++.
_________________
264 стр. Часть 4. Наследование
Для создания файла проекта Dev-C++ выполните следующие действия.
1. Выберите команду меню Файл => Создать => Проект ( File => New => Project ). Выберите в диалоговом окне Console Application и введите имя SeparateModules ( рис. 22.6 ).
Рис. 22.6. Выбор типа нового проекта
2. Щёлкните на кнопке ОК.
Dev-C++ откроет окно сохранения файла.
3. Выберите каталог, в котором должен быть сохранён проект.
4. Удалите main.срр из проекта, поскольку у вас уже есть модуль main( ).
5. Выберите команду меню Проект => Удалить из проекта ( Project => Remove From Project ).
6. Выберите main.срр и щёлкните на кнопке ОК.
7. Скопируйте файлы SeparatedMain.cpp, GraduateStudent.срр, Student.срр, Student.h и GraduateStudent.h в рабочую папку ( если они ещё не находятся там ).
8. Выберите команду меню Проект => Добавить к проекту ( Project => Add to Project ).
_________________
265 стр. Глава 22. Разложение классов
9. Выберите все исходные модули и щёлкните на кнопке ОК.
10. Выберите команду меню Выполнить => Перестроить всё ( Execute => Rebuild All ) для компиляции модулей проекта и создания выполнимой программы.
11. Щёлкните на вкладке Классы ( Classes ) в левом окне, чтобы увидеть описания всех классов программы ( рис. 22.7 ).
«Убедитесь, что просмотр классов корректно настроен.»
[Советы]
Рис. 22.7. Описание классов программы
12. Выберите команду меню Сервис => Параметры редактора ( Tools => Editor options ) и щёлкните на вкладке Обзор классов ( Class browsing ).
13. Включите опцию Включить обзор классов ( Enable class browser ) и другие опции, показанные на рис. 22.8.
Обратите внимание, как выводится информация о классах — при включённой опции вывода наследованных членов у класса GraduateStudent показаны две функции display( ).
_________________
266 стр. Часть 4. Наследование
Рис. 22.8. Типы информации о классах, доступные для отображения
14. Выберите первую функцию display( ) в списке и щёлкните на маленькой пиктограммке слева от неё.
При этом в окне редактора вы увидите файл Student.срр, причём курсор окажется на функции display( ). Выбор второй функции display( ) перенесёт нас к этой функции в файле GraduateStudent.срр.
Свойства проекта настроены по умолчанию. Для их изменения вы можете выполнить следующее.
15. Выберите команду меню Проект => Параметры проекта ( Project => Project Options ).
Например, вы можете выбрать вкладку настроек компилятора и отключить генерацию отладочной информации или изменить другие настройки.
Я думаю, вы убедитесь, что разбиение программы на отдельные исходные файлы облегчает её редактирование, внесение изменений и отладку.
_________________
267 стр. Глава 22. Разложение классов