ГЛАВА 2.
ОТНОШЕНИЯ МЕЖДУ КЛАССАМИ
2.1.
Наследование классов
В языке С++ абстракция данных осуществляется с помощью классов, инкапсулирующих типы данных и операции над ними. Однако объектно- ориентированный подход выходит за рамки простой инкапсуляции. При- меняя наследование и полиморфизм, в языке С++ можно на основе сущест- вующих классов создавать новые.
Наследование (inheritance) – это способность класса приобретать свой- ства ранее определенного класса. Один класс может наследовать структуру и поведение другого класса.
В языке С++ производный класс наследует все члены базового клас- са, за исключением конструкторов, деструктора, перегруженной операции присваивания и определения друзей класса. Таким образом, производный класс содержит в себе все данные-члены и функции-члены базового класса, добавляя к ним новые члены, определенные в нём самом. Кроме того, про- изводный класс может изменять (переопределять) любую наследуемую функцию-член.
При использовании механизма наследования в описании класса по- является новый раздел – защищённый (protected). Члены, помещённые в защищённый раздел, доступны из функций классов-наследников, но скрыты вне определения класса. Общие правила доступа относительно раз- делов класса в языке С++ представлены в табл. 7.
Определение производного класса начинается с указания типа насле- дования – public, protected или private и имени базового класса: class <имя производного класса>:
{public|protected|private} <имя базового класса>
67
Таблица 7 Правила доступа Раздел базового класса
Открытый
(public)
Защищенный
(protected)
Закрытый
(private)
Доступность из функций базового класса, функций дружественных классов и из дружественных функций
Да
Да
Да
Доступность из функций классов- наследников базового
Да
Да
Нет
Доступность из других классов и функций
Да
Нет
Нет
Открытое наследование. Открытые и
защищенные члены базового класса остаются, соответственно, открытыми и защищенными членами производного класса.
Защищенное наследование. Открытые и защищенные члены базового класса становятся защищенными членами производного класса.
Закрытое наследование. Открытые и защищенные члены базового класса становятся закрытыми членами производного класса.
Среди указанных видов наследования открытое наследование являет- ся наиболее важным и часто используемым.
2.2. Открытое наследование Открытое наследование устанавливает между классами отношение
«является», или в английской нотации «is-a».
При открытом наследовании все, что характеризует объекты класса- предка, является справедливым и для объектов класса-наследника. Это свойство называется
совместимостью типов объектов. Благодаря этому объект производного класса можно применять вместо объекта базового класса, но не наоборот.
Например, объекту базового типа можно присвоить объект производ- ного типа, указателю на объект базового типа – указатель на объект произ-
68 водного. Объект производного типа также может быть передан в качестве параметра в функцию, вместо объекта базового типа.
Пример 9. Реализовать иерархию классов Sphere («сфера») – Ball
(«мяч»), которая представлена на UML диаграмме (рис. 2.1).
Рис. 2.1. UML диаграмма иерархии классов Sphere – Ball
Определение класса «сфера» – файл sphere.h.
#ifndef SPHERE_H
#define SPHERE_H class Sphere { public:
//Конструкторы
Sphere();
69
Sphere(double iniRadius);
// Операции void resetRadius(double newRadius); double getRadius(); double getVolume(); void showInform(); protected: double radius; //радиус сферы
};
#endif
Реализация класса «сфера» – файл sphere.cpp
#include "sphere.h"
#include
#include using namespace std;
Sphere::Sphere(): radius(1.0){ }
Sphere::Sphere(double iniRadius){ if (iniRadius>0) radius=iniRadius; else radius=1.0;
} void Sphere::resetRadius(double newRadius){ if (newRadius>0) radius=newRadius; else radius=1.0;
} double Sphere::getRadius(){ return radius;
} double Sphere::getVolume(){ double rad3=radius*radius*radius; return (4.0*3.14*rad3)/3.0;
} void Sphere::showInform(){ cout<<"\n Radius = "< <<"\n Volume = "<}
Определение класса «мяч» – файл ball.h
#ifndef BALL_H
#define BALL_H
#include "sphere.h"
70
#include using namespace std; class Ball: public Sphere{ public:
//конструкторы
Ball();
Ball(double iniRadius, const string iniName);
//дополнительные или измененные операции string getName(); void reName (const string newName); void resetBall(double newRadius, const string newName); void showInform(); private: string name;
};
#endif
Реализация класса «мяч» – файл ball.cpp
#include "ball.h"
#include using namespace std;
Ball::Ball(): Sphere(){ setName("NoName");
}
Ball::Ball(double iniRadius, const string iniName):
Sphere(iniRadius), name(iniName){} void Ball:: reName(const string newName){ name = newName;
} string Ball::getName(){ return name;
} void Ball::resetBall(double newRadius, const string newName) { radius = newRadius; name = newName;
} void Ball::showInform(){ cout<<" Ball type - "< Sphere::showInform();
}
71
Конструктор производного класса выполняется после конструктора ба- зового класса. Это правило действует и в цепочках наследования любой длины.
Например, конструктор класса Ball выполняется после конструкто- ра класса Sphere.
Ball::Ball(): Sphere(){setName("NoName");}
Ball::Ball(double iniRadius, const string iniName):
Sphere(iniRadius), name(iniName){}
Конструкторы класса Ball вызывают соответствующие конструкто- ры класса Sphere. Для указания, какой именно из конструкторов базового типа следует вызвать в каждом конкретном конструкторе класса-потомка, используется синтаксис списка инициализаторов. Если конструктор базо-
вого класса отсутствует в списке инициализации, используется конструк- тор базового класса по умолчанию. В этом случае конструктор без пара- метров для класса Ball может выглядеть следующим образом:
Ball::Ball(){ setName("NoName");
}
Если в классе-потомке не определен ни один конструктор, то будет создан конструктор по умолчанию, который вызовет конструкторы по умолчанию всех предков.
Важно, чтобы в иерархии классов всегда были определены конструк- торы по умолчанию.
Конструктор производного класса отвечает за инициализацию всех элементов данных, добавленных к унаследованным данным из базового класса. Конструктор базового класса выполняет инициализацию унаследо- ванных элементов данных.
72
Деструктор производного класса выполняется перед деструктором ба- зового класса. В цепочках наследования произвольной длины деструкторы вызываются в порядке обратном порядку вызова конструкторов.
Соответственно, в нашем примере вначале выполнится деструктор класса Ball, затем — деструктор класса Sphere. Поскольку они явно не определены, то используются автоматические деструкторы.
Наследование не открывает доступ к закрытым членам. Если бы пе- ременная radius была объявлена как private, она была бы недоступна в классе Ball. Тогда внутри класса Ball для доступа к ней пришлось бы использовать открытые функции класса Sphere — resetRadius и getRadius
. Но всегда следует помнить, что каждый вызов функции при- водит к увеличению накладных расходов. Поэтому если наследникам ну- жен прямой доступ к полям предка, рекомендуют использовать специфика- тор доступа protected. class Ball{ protected: double radius; //радиус сферы
}; void Ball::resetBall(double newRadius, const string newName) { radius = newRadius; name = newName;
}
В реализации класса Ball можно вызывать функции, наследуемые от класса Sphere. Если бы поле radius было объявлено как private, функция resetBall должна была бы вызывать наследуемую функцию resetRadius. void Ball::resetBall(double newRadius, const string newName) { resetRadius(newRadius); name = newName;
}
73
Функция showInform переопределяется в классе Ball. При этом она вызывает унаследованную версию функции класса предка
Sphere::showInform. Для разрешения коллизии имён используется операция разрешения области видимости ::. void Ball::showInform(){ cout<<" Ball type - "<
Sphere::showInform();
}
При переопределении функции базового класса в производном классе списки параметров могут не совпадать. При этом замещающая функция пе- реопределяет исходную функцию, но с другим списком параметров.
Перегрузка при этом не происходит, так как она возможна только в од- ном пространстве имен. Каждый класс имеет свое пространство имен. Сле- довательно, производный класс вводит новое пространство имен.
Объекты производного класса могут вызывать открытые функции- члены базового класса. В этом выражается связь между производным и ба- зовым классом, например:
Ball myBall(5.0, "Volleyball"); cout<Две другие важные связи заключаются в том, что указатель базового класса может указывать на производный объект без явного преобразования типов. Ссылка на базовый класс тоже может иметь значением адрес объек- та производного класса.
Ball myBall(5.0, "Volleyball");
Sphere &pb = myBall; Sphere *pt = &myBall;
Sphere *p = new Ball(9.0, "basketball"); pb.showInform(); cout << pb.getRadius() << endl; pt->showInform(); cout << pt->getVolume() << endl; p->showInform(); cout << p->getVolume() << endl;
74
Однако указатель или ссылка базового типа позволяет вызывать только функции базового класса, поэтому воспользоваться pb, pt или p для вызова функции getName() производного класса нельзя.
Следует обратить внимание и на то, что вызов функции showInform() через указатели p, pt или ссылку pb на базовый класс обратится к реализации этой функции в классе Sphere, игнорируя ее пе- реопределение в классе Ball.
Пример 10. Описать иерархию классов Person – Student. Класс
Student не должен иметь доступ к личным данным, содержащимся в классе Person. Методы класса заданы на UML-диаграмме (рис. 2.2).
Рис. 2.2. UML диаграмма иерархии классов Person – Student
75
#pragma once
#include using namespace std; class Person { private: string name; int age; public:
Person(const string& name = "noname", int age = 18) : name(name), age(age) {} friend ostream & operator<< (ostream & os, const Person & p)
{ os << p.name << " " << p.age << endl; return os;
}
}; class Student: public Person
{ private: string univ; // Университет int m; int* marks; // Оценки public:
Student() : univ("МГУ"), m(3)
{ marks = new int[m]; for (int i = 0; i }
Student(const string& name, int age, const string& u, int m):
Person(name, age), univ(u), m(m)
{ marks = new int[m]; for (int i = 0; i }
Student(const Student& s): Person(s), univ(s.univ), m(s.m)
{ marks = new int[s.m]; for (int i =0; i }
Student() { delete[] marks; }
76
Student& operator=(const Student& s)
{ if (&s != this)
{ delete[] marks;
Person::operator=(s); univ = s.univ; m = s.m; marks = new int[m]; for (int i = 0; i } return *this;
}; void setMark(int i, int mark)
{ if (i < 0 || i >= m) throw - 1; marks[i] = mark;
} friend ostream & operator<< (ostream & os, const Student & s)
{ os<<(Person)s; os << s.univ << endl; for (int i = 0; i < s.m; ++i) os << s.marks[i] << " "; os << endl; return os;
}
};
#include
#include
#include "PS.h" using namespace std; int main() {
Student s("Pinokkio", 18, "ЮФУ", 3); s.setMark(0, 5); s.setMark(1, 4); cout << s;
Student s1(s); cout << s1;
Student s2; cout << s2; s2 = s1;
77 cout << s2; system("PAUSE"); return 0;
}
Наличие динамически выделяемой памяти int* marks в классе
Student создаёт дополнительные проблемы. В этом случае нельзя ис- пользовать создаваемые по умолчанию функции: конструктор без парамет- ров, конструктор копии, деструктор и операцию присваивания. Необходи- мо предусмотреть их реализацию в классе.
Конструктор без параметров использует список инициализаторов для членов-данных univ, m. Выделение памяти для массива оценок marks и его инициализация происходит в теле конструктора.
Student(): univ("МГУ"), m(3){ marks = new int[m]; for (int i = 0; i
}
Инициализация членов-данных, унаследованных от класса Person, может выполниться только внутри конструктора класса Person. Вызов конструктора предка возможен только в списке инициализаторов конструк- тора наследника. В данном примере его нет, поэтому будет вызван конст- руктор по умолчанию класса предка. Если у класса предка нет конструкто- ра по умолчанию, то произойдёт ошибка.
Если предполагается иерархия наследования, рекомендуется для каждо- го класса в иерархии определять конструктор по умолчанию.
Конструктор с параметрами требует явного указания конструктора предка в списке инициализаторов.
Student(const string& name, int age, const string& u, int m):
Person(name, age), univ(u), m(m){ marks = new int[m]; for (int i = 0; i}
78
Здесь Person(name, age) — это вызов конструктора предка. Ес- ли не написать вызов Person(name, age), то произойдет вызов конст- руктора без параметров, который проинициализирует поля name и age значениями по умолчанию.
В деструкторе необходимо только освободить память, занимаемую массивом marks , а память, выделенная для univ и Person, будет осво- бождена автоматически при вызове соответствующих деструкторов в эпи- логе деструктора Student().
Student() { delete[] marks;
// эпилог
}
Рассмотрим конструктор копии класса Student.
Student(const Student& s): Person(s), univ(s.univ), m(s.m) { marks = new int[s.m]; for (int i =0; i}
Заметим, что Person(s) будет работать корректно благодаря тому, что ссылке на объект класса предка можно присвоить ссылку на объект класса потомка. При этом происходит приведение типа-наследника к базо- вому типу. Такое приведение называется upcast.
Рассмотрим преобразование типов в конструкторе копии класса
Student
, которое возникает при вызове конструктора копии Person(s) в списке инициализации.
Мы фактически параметру типа const Person & присваиваем переменную типа const Student &.
Аналогичные преобразования также могут выполняться как для указателей, так и для самих объектов. Преобразования типов для параметров при вызо- ве функций встречаются достаточно часто, в том числе и для параметра this
. Например, если вызывается для потомка унаследованная функция предка.
79
Поскольку передача параметров
сводится к выполнению операции присваивания, то рассмотрим преобразование в иерархии «предок- потомок» на примере операции присваивания.
Для преобразования типов в иерархии «предок-потомок» работает правило
: переменной типа предок можно присвоить переменную типа по- томок, но не наоборот.
Person p("Иванов", 20);
Student s("Петров", 19, "ЮФУ", 3); p = s;
При присваивании объекта производного класса переменной базово- го класса происходит копирование только полей базового класса, остальная часть информации объекта производного класса будет утеряна.
Попытка присвоить объекту класса наследника объект класса предка приведёт к ошибке компиляции. s = p; // ошибка компиляции
При работе с указателями и ссылками на объекты предка и наследни- ка действует аналогичное правило преобразования типов. Указателю или ссылке на объект базового класс можно присвоить адрес объекта или ссыл- ку на объект, соответственно, производного класса, но не наоборот.
Person* pp = &p;
Student* ss = &s; pp = ss; ss = pp; // ошибка компиляции
Person& rp = s;
Student& rs = p; // ошибка компиляции
Таким образом, если мы напишем Person& rp = s;, тогда rp бу- дет давать доступ только к двум полям объекта s, унаследованным от
Person
. Именно поэтому в конструкторе копии класса Student не воз- никало проблем с вызовом конструктора копии Person(s) в списке ини- циализации.
80
Операция присваивания будет реализована несколько сложнее:
Student& operator=(const Student& s){ if (&s != this){ delete[] marks;
Person::operator=(s); univ = s.univ; m = s.m; marks = new int[m]; for (int i = 0; i
} return *this;
};
Операция присваивания не наследуется. Поэтому, чтобы выполнить присваивание для private полей, унаследованных от базового класса
Person
, необходимо выполнить операцию присваивания, определённую в классе Person.
Person::operator=(s);
Операция вывода в поток демонстрирует ещё одну особенность об- ращения к функции, определённой для предка. friend ostream & operator<< (ostream & os, const Student & s) { os<<(Person)s; os << s.univ << endl; for (int i = 0; i < s.m; ++i) os << s.marks[i] << " "; os << endl; return os;
}
Поскольку операция вывода в поток является внешней, то к ней не- возможно применить разрешение области видимости. Поэтому использует- ся явное приведение типа. os<<(Person)s;
Обратное действие, когда происходит приведение базового типа к типу-наследнику, называется downcast. В этом случае необходимо исполь- зовать явное приведение типов с помощью шаблонной функции static_cast<тип_наследника>.
81
Добавим в класс Student функцию get_univ(), которая возвра- щает название учебного заведения, где учится студент. Такой функции нет, и не может быть в Person. class Student : public Person{ public: string get_univ() const{ return univ;
}
};
Теперь рассмотрим ситуацию, когда нам может понадобиться приве- дение базового типа к типу наследника:
Person *p = new Student("Петров", 19, "ЮФУ", 3); p->get_univ(); // ошибка компиляции
При вызове p->get_univ()произойдёт ошибка компиляции, так как в Person не определена функция get_univ(). Для корректного вы- зова указатель p нужно привести к типу *Student.
// Совеременный стиль: static_cast(pp)->get_univ();
// Старый стиль:
((Student*)pp)->get
_
univ();
Аналогичная ситуация возникает и при использовании ссылок:
Person & rp = *new Student("Петров", 20, "ЮФУ", 3); static_cast(rp).get_univ(); delete &rp;
Преобразование downcast в C++ возможно только для указателей или ссылок на объекты.
Корректное преобразование downcast возможно только как обратное преобразование к upcast.