Основы программирования — второй семестр 08-09; Михалкович С.С.; VI часть

Материал из Вики ИТ мехмата ЮФУ
Перейти к: навигация, поиск

Наследование

Основные определения

Иерархическая классификация животных в биологии

Наследование в программировании возникло как ответ на реальные отношения наследования классов в реальном мире и прикладных задачах.

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

Пример. Главный алгоритм работы операционной системы.

* начальная инициализация
* цикл обработки сообщений
    если сообщение пришло то 
      обработать сообщение 
    до сообщения «Конец»
* заключительные действия

Как видим, этот алгоритм тривиален и ничего не говорит о том, как работает ОС.
————————————

Поскольку главный алгоритм сложной системы не существует или тривиален, то для программирования работы этой сложной системы мы:

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

Примеры зависимостей между классами

  1. класс содержит в качестве поля объект другого класса
  2. в методе класса параметром является объект другого класса
  3. метод класса вызывает статический метод другого класса
  4. один из классов является разновидностью другого

Определение. Наследованием называется такая зависимость между классами, при которой один из классов является разновидностью другого.

При наследовании все члены базового класса (поля, методы, свойства) переходят в производный (наследуются им). Кроме того, производный класс может добавлять новые члены (поля, методы, свойства) и переопределять (замещать) методы базового класса.

Для классов, связанных наследованием, используют следующие термины:

  • базовыйпроизводный
  • предокпотомок
  • надклассподкласс

Каковы цели наследования?

  1. Повторное использование кода.
  2. Обеспечение вариабельности и изменчивости кода.

Наследование — это расширение или сужение?
Наследование — это расширение интерфейса класса, но сужение количества объектов (представителей) класса.

Наследование на примере Student - SeniorStudent

Про переменную Self см. здесь.

Рассмотрим следующий пример наследования:

Пример наследования

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

<xh4> Базовый класс Student </xh4>

interface
uses System;
   
const
  /// Минимальный допустимый возраст студента
  MIN_AGE = 1;
  /// Максимальный допустимый возраст студента
  MAX_AGE = 120;
  
  /// Первый курс
  FIRST_COURSE = 1;
  /// Максимальный курс для студентов младших курсов
  LAST_UNDERGRADUATE_COURSE = 4;

type
  /// Студент
  Student = class
  private
    fName: string;
    fAge, fCourse, fGroup: integer;

  public
    /// Имя — только на чтение
    property Name: string read fName;
    /// Возраст — только на чтение
    property Age: integer read fAge;
    /// Курс — только на чтение
    property Course: integer read fCourse;
    /// Группа — только на чтение
    property Group: integer read fGroup;
    
    /// <summary>
    /// Создает нового студента
    /// </summary>
    /// <param name="Name">Имя (пустое недопустимо)</param>
    /// <param name="Age">Возраст (отрицательный или больший MAX_AGE недопустим)</param>
    /// <param name="Course">Курс (отрицательный или больший LAST_UNDERGRADUATE_COURSE недопустим)</param>
    /// <param name="Group">Группа (отрицательная недопустима)</param>
    constructor Create(Name: string; Age, Course, Group: integer);
    
    procedure SetAge(Age: integer);
    procedure SetGroup(Group: integer);
    
    /// Переводит студента на следующий курс, если он меньше LAST_UNDERGRADUATE_COURSE
    procedure NextCourse;
    
    procedure Print;
    procedure Println;
  end;
  
implementation

constructor Student.Create(Name: string; Age, Course, Group: integer);
begin
  if Name <> '' then
    fName := Name
  else
    raise new Exception(
    'Попытка присвоить студенту пустое имя!');
    
  SetAge(Age);
  
  if (Course >= FIRST_COURSE) and (Course <= LAST_UNDERGRADUATE_COURSE) then
    fCourse := Course
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + LAST_UNDERGRADUATE_COURSE.ToString + ']!');
    
  SetGroup(Group);
end;

procedure Student.SetAge(Age: integer);
begin
  if (Age >= MIN_AGE) and (Age <= MAX_AGE) then
    fAge := Age
  else
    raise new Exception(
    'Выход за границы диапазона допустимого возраста [' + 
    MIN_AGE.ToString + '..' + MAX_AGE.ToString + ']!');
end;

procedure Student.SetGroup(Group: integer);
begin
  if (Group > 0) then
    fGroup := Group
  else
    raise new Exception(
    'Попытка присвоить группе отрицательный номер!');
end;

procedure Student.NextCourse;
begin
  if fCourse < LAST_UNDERGRADUATE_COURSE then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + LAST_UNDERGRADUATE_COURSE.ToString + ']!');
end;

procedure Student.Print;
begin
  WriteFormat(
    'Имя: {0}  Возраст: {1}  Курс: {2}  Группа: {3}  ',
    fName, fAge, fCourse, fGroup);
end;

procedure Student.Println;
begin
  Print;
  writeln();
end;

<xh4> Производный класс SeniorStudent </xh4> SeniorStudent — студент старших курсов.
Помимо имени, возраста, курса и группы:

  • у него будет научный руководитель (Advisor: Teacher)
  • он будет знать тему курсовой работы, которую получает от своего научного руководителя
  • и для него будет известно, какую он получает академическую степень Degree (бакалавра, специалиста или магистра)

Будем считать, что класс преподавателя у нас уже есть, и у него есть метод SayCourseWorkTheme(MyStudent: SeniorStudent): string.

Приступим к реализации SeniorStudent.
Для начала нам понадобится вспомогательный тип DegreeType (академическая степень):

type
  /// Академическая степень (Бакалавр, Специалист, Магистр)
  DegreeType = (Bachelor, Specialist, Magister);

Теперь напишем интерфейс нашего старшекурсника:

  /// Студент старших курсов
  SeniorStudent = class (Student)
  private
    fAdvisor: Teacher;
    fDegree: DegreeType;

  public
    /// Научный руководитель — только на чтение
    property Advisor: Teacher read fAdvisor;
    /// Образовательная степень — только на чтение
    property Degree: DegreeType read fDegree;
    
    /// <summary>
    /// Создает нового студента старших курсов
    /// </summary>
    /// <param name="Name">Имя (пустое недопустимо)</param>
    /// <param name="Age">Возраст (отрицательный или больший MAX_AGE недопустим)</param>
    /// <param name="Course">Курс (отрицательный или больший MAX_COURSE недопустим)</param>
    /// <param name="Group">Группа (отрицательная недопустима)</param>
    /// <param name="Advisor">Научный руководитель</param>
    /// <param name="EduModel">Академическая степень (бакалавр, специалист, магистр)</param>
    constructor Create(Name: string; Age, Course, Group: integer; 
      Advisor: Teacher; Degree: DegreeType);
    
    /// Возвращает тему курсовой работы 
    function CourseWorkTheme: string;
    
    procedure NextCourse;
    
    procedure Print;
    procedure Println;
  end;

Как CourseWorkTheme и NextCourse мы знаем:

function SeniorStudent.CourseWorkTheme: string;
begin
  Result := fAdvisor.SayCourseWorkTheme(self);
end;

procedure SeniorStudent.NextCourse;
begin
  var lastCourse: integer;
  case fDegree of
    Bachelor:   lastCourse:= 4;
    Specialist: lastCourse:= 5;
    Magister:   lastCourse:= 6;
  end;

  if fCourse < lastCourse then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + lastCourse.ToString + ']!');
end;

Замечание. В процессе разработки возникла проблема доступа к приватным полям предка (fCourse)
Если Student и SeniorStudent реализованы в одном модуле, то такой проблемы нет и доступ возможен, однако, если они они реализованы в разных модулях, то данный код не откомпилируется.
Исправим это в дальнейшем, а пока будем считать, что классы реализованы в одном модуле.

Заметим, что процедура NextCourse была полностью переопределена в потомке.
Но, как правило, одноименный метод в потомке не переписывается полностью, а вызывает соответствующий метод предка.

Напомним, что переопределяющий метод называется также замещающим.

Для вызова в методе Method замещенного метода предка используется конструкция

inherited Method

Inherited — значит унаследованный.

Воспользуемся этим при реализации оставшихся методов:

constructor SeniorStudent.Create(Name: string; Age, Course, Group: integer; 
  Advisor: Teacher; Degree: DegreeType);
begin
  inherited Create(Name, Age, Course, Group);
  
  if Advisor <> nil then
    fAdvisor := Advisor
  else
    raise new Exception(
    'Параметр «Advisor» конструктора является нулевой ссылкой!');
    
  fDegree := Degree;
end;

procedure SeniorStudent.Print;
begin
  inherited Print;
  
  write('Преподаватель: ');  
  fAdvisor.Print;
  
  write('Академическая степень: ');
  case fDegree of
    Bachelor:   write('Бакалавр');
    Specialist: write('Специалист');
    Magister:   write('Магистр');
  end;
end;

procedure SeniorStudent.Println;
begin
  Print;
  writeln();
end;

Полный текст модуля University

Вызов унаследованных конструкторов

Конструктор — это функция, создающая объект класса и возвращающая ссылку на него.

Вспомним, как выглядел конструктор класса SeniorStudent (студента старших курсов):

type SeniorStudent = class (Student)
   ...
  constructor SeniorStudent.Create(Name: string; Age, Course, Group: integer; 
    Advisor: Teacher; Degree: DegreeType);
  begin
{*} inherited Create(Name, Age, Course, Group);
    
    if Advisor <> nil then
      fAdvisor := Advisor
    else
      raise new Exception(
      'Параметр «Advisor» конструктора является нулевой ссылкой!');
    fDegree := Degree;
  end;

...

var ss: SeniorStudent := new SeniorStudent(...);

(*) Унаследованный конструктор базового класса вызывается как процедура, т.е. возвращаемое значение игнорируется.

Вопрос. Создает ли вызов унаследованного конструктора базового класса объект в динамической памяти?
Ответ. Нет. Объект создается вызовом конструктора производного класса. А вызов унаследованного конструктора производится как вызов обычного метода.

Вопрос. Можно ли совершать вызов унаследованного конструктора не первым оператором в коде основного конструктора?
Ответ. В PascalABC.NET нельзя, как и в таких языках, как Java, C++ и C#.

Принцип «открыт - закрыт»

Принцип «открыт - закрыт» состоит в следующем: код программы должен быть закрыт для изменения текста и открыт для модификации поведения и функциональности.

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

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

Пример. Очередь с подсчетом элементов.
Реализуем класс очереди с подсчетом элементов (CountingQueue<T>) как производный от обычной очереди (Queue<T>) (см. модуль Collections):

interface
uses Collections;
  
type 
  /// Очередь с подсчетом элементов
  CountingQueue<T> = class (Queue<T>)
  private
    /// Количество элементов
    fCount: integer;
    
  public
    /// Количество элементов
    property Count: integer read fCount;
    
    /// Создает пустую очередь
    constructor Create;
    
    /// Добавляет элемент x в хвост очереди
    procedure Enqueue(x: T);
    /// Возвращает значение элемента в голове, удаляя его из очереди
    function Dequeue: T;
  end;

implementation

constructor CountingQueue<T>.Create;
begin
  inherited Create;
  fCount := 0;
end;

{Добавляет элемент x в хвост очереди}
procedure CountingQueue<T>.Enqueue(x: T);
begin
  inherited Enqueue(x);
  fCount += 1;
end;

{Возвращает значение элемента в голове, удаляя его из очереди}
function CountingQueue<T>.Dequeue: T;
begin
  Result := inherited Dequeue;
  fCount -= 1;
end;

end.

Замечание. В классе CountingQueue часть методов унаследована от класса Queue без замещения (например, метод IsEmpty).

Наследование и включение

Реализуем класс CountingQueue другим способом: включим очередь Queue в качестве приватного поля в класс CountingQueue и переопределим все методы очереди, делегируя вызов методов внутреннему объекту класса Queue:

interface
uses Collections;
  
type 
  /// Очередь с подсчетом элементов (на базе Queue)
  CountingQueue<T> = class
  private
    /// Встроенная очередь
    fq: Queue<T>;  // включение объекта класса
    /// Количество элементов
    fCount: integer;
    
  public
    /// Количество элементов
    property Count: integer read fCount;
    
    /// Создает пустую очередь
    constructor Create;
    
    /// Добавляет элемент x в хвост очереди
    procedure Enqueue(x: T);
    /// Возвращает значение элемента в голове, удаляя его из очереди
    function Dequeue: T;
    /// Возвращает значение элемента в голове очереди, не удаляя его
    function First: T;
    /// Возвращает истину, если очередь пуста
    function IsEmpty: boolean;
  end;

implementation

constructor CountingQueue<T>.Create;
begin
  fq := new Queue<T>;
  fCount := 0;
end;

{Добавляет элемент x в хвост очереди}
procedure CountingQueue<T>.Enqueue(x: T);
begin
  fq.Enqueue(x);
  fCount += 1;
end;

{Возвращает значение элемента в голове, удаляя его из очереди}
function CountingQueue<T>.Dequeue: T;
begin
  Result := fq.Dequeue;
  fCount -= 1;
end;

{Возвращает значение элемента в голове очереди, не удаляя его}
function CountingQueue<T>.First: T;
begin
  Result := fq.First;
end;

{Возвращает истину, если очередь пуста}
function CountingQueue<T>.IsEmpty: boolean;
begin
  Result := fq.IsEmpty;
end;

end.

В данном случае способ реализации с включением объекта класса хуже способа наследования от этого класса по следующим причинам:

  • Мы считаем класс CountingQueue разновидностью класса Queue, поэтому он должен содержать все, что есть в Queue, и, возможно, что-то еще
  • При включении нам придется, поэтому, переопределять все методы, а при наследовании — только необходимые

А вот в примере, где мы определяли класс SimpleSet на базе динамического массива, мы пользовались включением.
Почему тогда не использовалось наследование? Зададим вопрос: а является ли множество разновидностью динамического массива? Ответ: нет.

<xh4> Когда выбирать наследование, а когда — включение? </xh4> Есть два класса: A и B.
Зададим вопросы:

  1. B является разновидностью A?
    (B is A?)

    Если ответ «да», то это — наследование.
  2. A состоит из B?
    (A has B?
    )
    Если ответ «да», то это — включение.
  3. A использует объект B для реализации?
    (A use B?)

    Если ответ «да», то это — включение.

Однако, не всегда ответы на эти вопросы однозначны. Приведем несколько примеров.

  • Matrix является разновидностью Vector.
    Да. Можем рассматривать Matrix как вектор-строку, состоящую из векторов-столбцов.
  • Vector является разновидностью Matrix.
    Тоже да. Можем рассматривать Vector как Matrix из одного столбца.
  • Matrix состоит из Vector.
    Да.

Как видим, эти утверждения противоречивы. Становится понятно, что не всегда можно однозначно определить отношения между классами. Это зависит от задачи.

Приведем еще один пример: Circle и Ellipse.
С точки зрения аналитической геометрии, окружность — это разновидность эллипса. А с точки зрения программирования, возможно, удобнее унаследовать эллипс от окружности (Ведь окружность характеризуется координатами центра и радиусом, а эллипс — координатами центра и двумя радиусами. Поэтому можно реализовать эллипс, добавив к окружности еще один радиус).

Виды отношений между классами (нотация UML-диаграмм классов)

  1. Ассоциация (связь)

    Ассоциация

  2. Агрегация (слабая форма has — состоит логически)

    Агрегация

  3. Композиция (сильная форма has — состоит физически)

    Композиция

  4. Наследование (обобщение)

    Наследование

  5. Реализация интерфейсов

    Реализация интерфесов
    (пока не знаем)

Пример 1. UML-диаграмма классов: персона, преподаватель, студент, студент старших курсов, группа.

StudentTeacher.png

Пример 2. UML-диаграмма факультета.

Faculty.png

Вид доступа protected. Рефакторинг метода NextCourse

Иногда возникает необходимость разрешить доступ к полям или методам предка в методе потомка, но запретить для всех остальных. Такой вид доступа называется protected (защищенный).

Поскольку в одном модуле все имеют доступ и к приватной, и к публичной секциям класса, то данный вид доступа в Pascal'е имеет смысл только, если предок и потомок находятся в разных модулях.

Вернемся к примеру Student <— SeniorStudent: и у обычного студента, и у студента старших курсов есть метод NextCourse

procedure Student.NextCourse;
begin
  if fCourse < LAST_UNDERGRADUATE_COURSE then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + LAST_UNDERGRADUATE_COURSE.ToString + ']!');
end;

...

procedure SeniorStudent.NextCourse;
begin
  var lastCourse: integer;
  case fDegree of
    Bachelor:   lastCourse := 4;
    Specialist: lastCourse := 5;
    Magister:   lastCourse := 6;
  end;
  if fCourse < lastCourse then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + lastCourse.ToString + ']!');
end;

Проведем рефакторинг (изменение кода с целью улучшения, не влияющее на функциональность) нашего кода, т.к., если классы Student и SeniorStudent находятся в разных модулях, то у SeniorStudent нет доступа к приватному полю fCourse предка Student.
Выход: сделать fCourse protected-полем:

Student = class
private
  fName: string;
  fAge, fGroup: integer;
protected
  fCourse: integer;
public
  ...
end;

Замечание. Делать protected-поля и методы надо только в крайнем случае, т.к. они ослабляют защиту доступа.

Теперь проведем рефакторинг метода NextCourse.
1. Заметим, что реализации этого метода у предка и потомка включают очень похожие части:

(Student)
  if fCourse < LAST_UNDERGRADUATE_COURSE then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + LAST_UNDERGRADUATE_COURSE.ToString + ']!');
(SeniorStudent)
  if fCourse < lastCourse then
    fCourse += 1
  else
    raise new Exception(
    'Выход за границы диапазона допустимых курсов [' + 
    FIRST_COURSE.ToString + '..' + lastCourse.ToString + ']!');

Оформим их в специальный метод ProtectedNextCourse(lastCourse) и поместим данный метод в protected-секцию класса Student. Тогда им можно будет пользоваться в методах классов Student, SeniorStudent, и нигде более.

Поскольку упоминание поля fCourse теперь полностью находится в методе ProtectedNextCourse, это поле вновь можно сделать приватным в классе Student.

2. Оформим участок кода метода SeniorStudent.NextCourse:

  var lastCourse: integer;
  case fDegree of
    Bachelor:   lastCourse := 4;
    Specialist: lastCourse := 5;
    Magister:   lastCourse := 6;
  end;

в виде глобальной функции LastCourseByDegree(degree), которая возвращает последний курс, соответствующий академической степени degree.

Полный текст модулей после рефакторинга

Наследование и выявление общего предка

Пример 1.
Рассмотрим следующие классы:

Классы студента и преподавателя

Вынесем то общее, что есть в классах Student и Teacher в базовый класс Person:

Наследование классов от Person

Реализация полученных классов

Пример 2.
Рассмотрим следующие классы:

Point
   - x
   - y
   + Draw()
Circle
   - radius
   + Draw()

Какой класс является предком, а какой потомком?

Пусть потомок — окружность.
Замечание. Данный вид наследования неудачен, т.к. окружность не наследуется от «точки с возможностью рисования», а скорее содержит точку как центр. Но, и окружность, и точка являются разновидностями некоторого класса, содержащего координаты и метод Draw.
Поэтому правильнее сделать так :

ShapeInheritance.png

Присваивание в иерархии предок-потомок

Рассмотрим уже знакомую иерархию классов:

PersonStudent.png

Что будет происходить при выполнении следующего кода?

begin
  var s: Student := new Student('Иванов', 17, 1, 11);
  var p: Person := new Person('Петров', 23);
  s := p; 
  p := s;
end;

Правило: Переменной базового класса можно присваивать объект производного класса. Это соответствует тому, что ячейка памяти отведённая под переменную базового класса, будет хранить адрес объекта производного класса. Переменной производного класса нельзя присвоить объект базового класса.
Соответствующий принцип в объектно-ориентированном программировании получил название принципа подстановки: во всех ситуациях, где используется объект базового класса, можно использовать объект производного класса без потери функциональности.

В соответствии со сказанным получаем:

s := p; // ошибка компиляции

p := s; // корректное присваивание

Пример.
Группу студентов послали на субботник. Без потери функциональности можно отправить на субботник:

  • студентов младшекурсников
  • студетнтов старшекурсников
  • аспирантов

Что же происходит при присваивании

p := s;
?

В этом присваивании возникает приведение типа от производного класса к базовому (UpCast).

Приведение же от базового типа к производному (DownCast) осуществляется только с помощью явного приведения типа:

var p: Person;
p := new Student('Иванов', 17, 1, 11);

var s : Student;
s := Student(p); // явное приведение типа
s.NextCourse;

Примечание. Для явного приведения типа не обязательно явно создавать переменную, можем воспользоваться уже известной конструкцией

Student(p).NextCourse;

Замечание. Если к моменту явного приведения типа в переменной p находится объект базового класса, то возникнет исключение и программа завершится с ошибкой.

var p := new Student('Петров', 23);
var s := Student(p); // ошибка выполнения

Операции is и as

<xh4> is </xh4>

p is Student

Возвращает true, если переменная p ссылается в данный момент на объект типа Student (или производного от него типа) и false в противном случае.

Пример.

if p is Student then
  Student(p).NextCourse;

<xh4> as </xh4>

p as Student

Возвращает ссылку на студента, если преобразование возможно, и nil в противном случае.

Пример.

var s: Student := p as Student;
if s <> nil then
  s.NextCourse;

Замечание. Код с as будет работать немного быстрее, т.к. в первом примере преобразование, фактически, осуществляется дважды: первый раз — в операции is, а второй — при явном приведении типа.