3. Иерархия

3.1. Наследование классов

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

§ 28. Агрегация и наследование. Рассмотрим пример. Положим, мы создаем программу – редактор векторной графики. Для представления геометрических фигур используются следующие классы: многоугольник Polygon, треугольник Triangle, круг Circle. Вершины многоугольников и центр круга представляются классом точки Point. Положим также, мы реализуем для всех фигур метод смещения void Move (float dx, float dy), который должен перемещать все точки фигуры на указанные величины по X и Y. Например, этот метод будет использоваться при перемещении фигуры курсором. Проанализируем следующий код.


// Точка.
class Point
{
  // Координаты точки.
  private float x;
  private float y;
  // Перемещение точки.
  public void Move (float dx, float dy)
  {
    x += dx;
    y += dy;
  }
}
// Треугольник.
class Triangle
{
  // Вершины треугольника.
  private Point vA;
  private Point vB;
  private Point vC;
  // Перемещение треугольника – перемещаем все вершины.
  public void Move (float shiftX, float shiftY)
  {
    vA.Move(shiftX, shiftY);
    vB.Move(shiftX, shiftY);
    vC.Move(shiftX, shiftY);
  }
}
// Многоугольник.
class Polygon
{
  // Массив вершин многоугольника.
  private Point[] vertices;
  // Перемещение многоугольника – перемещаем все вершины.
  public void Move (float shiftX, float shiftY)
  {
    for (int i = 0; i < vertices.Length; i++)
    {
      vertices[i].Move(shiftX, shiftY);
    }
  }
}
// Круг.
class Circle
{
  // Центр круга – точка.
  private Point center;
  // Радиус круга.
  private float radius;
  // Перемещение круга – перемещаем центр, радиус при этом не изменяется.
  public void Move (float shiftX, float shiftY)
  {
    center.Move(shiftX, shiftY);
  }
}

Сравните методы Move в классах Circle, Polygon и Triangle. Хотя их код отличается, эти методы логически выполняют одно и то же: перебирают все поля-точки класса соответствующей фигуры и вызывают для каждой из этих точек метод Move. Изменив способ хранения данных в классах Triangle и Circle на массив точек, мы сделаем код методов Move одинаковым во всех трех классах:


// Здесь и далее в настоящем параграфе мы будем добавлять цифры к именам 
// рассматриваемых классов, чтобы различать варианты реализации.
class Triangle2
{
  private Point[] vertices;
  public Triangle2()
  {
    // Треугольник всегда «состоит» из трех точек – своих вершин.
    vertices = new Point[3];
  }
  // Сравните с методом Move класса Polygon из предыдущего листинга.
  public void Move(float shiftX, float shiftY)
  {
    for (int i = 0; i < vertices.Length; i++)
    {
      vertices[i].Move(shiftX, shiftY);
    }
  }
}
class Circle2
{
  private Point[] vertices;
  private float radius;
  public Circle2()
  {
    // Круг всегда «состоит» из одной точки – центра.
    vertices = new Point[1];
  }
  // Сравните с методом Move класса Polygon из предыдущего листинга.
  public void Move(float shiftX, float shiftY)
  {
    for (int i = 0; i < vertices.Length; i++)
    {
      vertices[i].Move(shiftX, shiftY);
    }
  }
}

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

Реализация метода Move для классов Triangle2 и Circle2 сложнее, чем исходный вариант. Однако если бы мы могли 1) исключить дублирование кода метода Move в этих трех классах (то есть записать его не в трех местах, а в одном) и 2) отразить рассмотренные семантические отношения между классами (отношение «является») в коде (то есть как-то показать, что методы Move этих классов делают по смыслу одно и то же, но применимо к разным разновидностям фигур), то мы бы, наоборот, снизили сложность программы. Предложим следующее решение: создадим класс Shape, вынесем в него общие части классов Triangle, Polygon и Circle – массив точек vertices и метод Move – и включим объект этого класса как поле в соответствующие классы фигур:


// Фигура, состоящая из точек.
class Shape
{
  // Точки.
  private Point[] vertices;
  // В конструкторе задаем число точек фигуры.
  public Shape(int n)
  {
    vertices = new Point[n];
  }
  // Смещение всех точек фигуры.
  public void Move(float shiftX, float shiftY)
  {
    for (int i = 0; i < vertices.Length; i++)
    {
      vertices[i].Move(shiftX, shiftY);
    }
  }
}
class Circle3
{
  private Shape shape;
  private float radius;
  public Circle3()
  {
    shape = new Shape(1);
  }
  public void Move(float shiftX, float shiftY)
  {
    shape.Move(shiftX, shiftY);
  }
}
class Triangle3
{
  private Shape shape;
  public Triangle3 ()
  {
    shape = new Shape(3);
  }
  public void Move(float shiftX, float shiftY)
  {
    shape.Move(shiftX, shiftY);
  }
}
class Polygon3
{
  private Shape shape;
  private float radius;
  public Polygon3 (int n)
  {
    shape = new Shape(n);
  }
  public void Move(float shiftX, float shiftY)
  {
    shape.Move(shiftX, shiftY);
  }
}

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


class Triangle4
{
  public Shape Shape;
      public Triangle4 ()
      {
        shape = new Shape(3);
      }
}
// Использование:
triangle.Shape.Move(1, 2);

Такое решение синтаксически гарантирует, что метод Move и другие общие для всех фигур методы из класса Shape будут иметь одно имя при обращении из рассматриваемых классов. Однако само поле Shape мы вполне можем назвать по-разному в разных классах.

Фактически, мы попытались реализовать иерархическое отношение между объектами типа «является» (is-a), или, по-другому, «частное – общее» (круг, треугольник, многоугольник «являются» фигурами, они – «частное» по отношению к фигуре – «общему»), используя отношение «агрегации» (has-a), или, по-другому, «часть – целое» (поле объекта – «часть» по отношению к самому объекту – «целому»).

Агрегация (aggregation) – иерархическое отношение между объектами (и между классами этих объектов) типа «часть – целое»; включение одного объекта в другой в виде поля или другим способом37.

На диаграммах UML отношение агрегации изображается стрелкой в виде закрашенного ромба в направлении от «целого» к «части»:

На следующем рисунке показана диаграмма объектов и соответствующее распределение памяти для экземпляра класса Circle (прямоугольники с подчеркнутым названием в нотации UML обозначают объекты, при этом после двоеточия указывается их тип – класс, а перед двоеточием необязательно может быть указано имя объекта):

Покажем отличие между отношениями «часть – целое» и «частное – общее» еще на одном примере: объекты класса Wheel (колесо) связаны отношением агрегации с объектами классов Car (автомобиль) и Bus (автобус), так как с точки зрения объектной модели колесо – это и часть автобуса, и часть автомобиля. Однако класс Vehicle (транспортное средство) связан с классами Car и Bus отношением «частное – общее». Так, мы могли бы ожидать, что поведение, свойственное транспортному средству («общему») свойственно и автомобилям, и автобусам («частному»). Например, если класс Vehicle имеет поле Capacity (вместимость), то это поле должны иметь все «частные» классы. При этом, с другой стороны, если класс Wheel («частное») имеет поле Radius (радиус), то его совершенно необязательно должны иметь классы Bus и Car.

Но вернемся к примеру с геометрическими фигурами. Рассмотренная нами проблема с отсутствием на уровне языка контроля за одинаковым именованием поля Shape классов Triangle, Polygon, Circle, возникающая при попытке моделирования семантического отношения «частное – общее» с использованием механизма агрегации, на первый взгляд, не такая уж существенная. Однако, как мы увидим далее, этот подход не позволяет нам делать ничего из того, что мы могли бы ожидать от иерархических типов данных. В объектно-ориентированных языках существует специальный механизм для реализации иерархических отношений «частное – общее» – наследование. Когда говорят об иерархии и иерархических типах данных всегда имеется в виду именно наследование классов. Наследование позволяет объявить класс, наследующий другой класс. В первом приближении можно представлять, что наследование синтаксически упрощает рассмотренный код с агрегацией, решая проблему синтаксически негарантированной идентичности имен общих методов: поля и методы, объявленные в наследуемом классе синтаксически доступны в наследующем классе, как если бы они были объявлены в нем. Проанализируем следующий код:


// Реализация с агрегацией.
class Circle3
{
  private float radius;
  private Shape base;
  public Circle3 ()
  {
    base = new Shape(1);
    radius = 1;
  }
  public void Move(float dx, float dy)
  {
    base.Move (dx, dy);
  } 
}
// Реализация с наследованием: класс Circle4 наследует класс Shape.
// Несколько упрощая:
// код класса Circle5 эквивалентен коду класса Circle3:
class Circle5 : Shape
{
  private float radius;
  public Circle5 () : base (1) 
  {
    radius = 1; 
  }
}
Circle5 c = new Circle5 ();
// Метод Move реализован только в классе Shape, 
// но мы его вызываем через Circle5
// как если бы он был определн в классе Circle5.
c.Move(1,2);

Разберем синтаксис, используемый в классе Circle5.

После имени наследующего, или производного, класса Circle5 через двоеточие указывается имя наследуемого, или базового, класса Shape. При создании экземпляра производного класса Circle5 сначала создается экземпляр базового класса Shape с использованием конструктора, указываемого после двоеточия в объявлении конструктора производного класса. В приведенном примере при вызове конструктора Circle5() сначала создается объект типа Shape с помощью указанного конструктора base(1), что обозначает вызов конструктора Shape(int) с аргументом 1, то есть создание фигуры с одной точкой. Сравните конструкторы в приведенном выше коде, в первом приближении они условно эквивалентны.

Открытые поля и методы объекта типа Shape доступны в методах класса Circle5 как если бы они были объявлены в нем же. При этом они вызываются через неявное обращение к переменной base, обозначающей экземпляр базового класса, по аналогии с тем, как мы обращаемся к полям класса из его методов через неявное обращение к переменной this, обозначающей экземпляр того же класса. Сравните это с вариантом реализации без наследования, где мы объявляем и инициализируем поле Shape base явно – поведение программы в обоих случаях будет идентично. Приведем еще пример:


class Circle5 : Shape
{
  private float radius;
  public Circle5 () : base (1) 
  {
    // Ключевое слово this обозначает текущий объект
    // (private Circle5 this) и может быть опущено.
    this.radius = 1; 
  }
  // Метод Move(Point) перегружает метод Move(float, float) базового класса,
  // так же как если бы оба метода были объявлены в классе Circle5.
  public void Move (Point dp)
  {
    // Ключевое слово base обозначает объект базового класса
    // (private Shape base) и может быть опущено.
        base.Move(dp.X, dp.Y);
  }
}
// Пользователь синтаксически не видит, 
// в каком именно классе (производном или базовом) объявлен 
// тот или иной вызываемй метод.
Circle4 c = new Circle4();
// 1) Вызывается метод базового класса Shape.Move (float, float).
c.Move (1, 2);
Point dp = new Point (3, 4);
// 2) Вызывается метод производного класса Circle4.Move (Point).
c.Move (dp);

Таким образом, наш класс Circle5 не просто неявно включает в себя экземпляр Shape base, но наследует его поведение (методы) и состояние (поля). Закрытые методы и поля базового класса (vertices) не видны, как и в случае агрегации, однако, как мы покажем в следующем параграфе, возможно отметить поля базового класса, чтобы они были видны в производном, но не видны пользователям этого производного класса.

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

Сформулируем следующее определение.

Наследование классов (inheritance) – иерархическое отношение между классами типа «частное – общее»; «частные» классы наследуют состояние и поведение (поля и методы) «общих» классов, то есть «частный» класс обладает как собственным состоянием и поведением (полями и методами), так и «унаследованным» состоянием и поведением, определенным в «общем» классе.

Термины, используемые для обозначения «общего» (наследуемого) и «частного» (наследующего) класса отличаются в разных языках программирования. Так, например, в C# и Python для обозначения «общего» и «частного» используются термины «базовый» (base) и «производный» (derived) соответственно. В языках Java и C++ - суперкласс (superclass) и подкласс (subclass). Термины «родительский» и «дочерний» также употребляются, но редко, более того, в некоторых языках они обозначаются вложенные (nested) классы, что не имеет прямого отношения к наследованию. В настоящей книге далее используются термины «базовый класс» и «производный класс».

Отношение наследования изображается в нотации UML не закрашенной треугольной стрелкой в направлении от производного класса к базовому.

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

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

Сокращение дублирования кода и синтаксическое отражение семантических связей между классами сами по себе – важные, но далеко не единственные (и не главные) практические задачи, решаемые наследованием классов. Однако, прежде чем перейти к «главным» задачам, в последующих параграфах мы подробнее остановимся на рассмотрении синтаксиса наследования и процесса конструирования экземпляров производного класса.

§ 29. Защищенные поля и методы. В рассмотренных в предыдущем параграфе примерах массив точек Point[] vertices в классе Shape не виден производным классам, так как он объявлен закрытым private. Однако, для многих задач нам хотелось бы иметь доступ к состоянию и поведению (или к части состояния и поведения – к некоторым полям и методам) базового класса из производного, но не раскрывать это состояние и поведение для пользователей производного. То есть объявить поле или метод, которое было бы открытым (public) для методов производного класса, но закрытым (private) для других классов.

Например, для класса Circle5 мы бы хотели объявить метод public Point GetCenter(), возвращающий точку-центр круга. Но делать массив Shape.vertices открытым или создавать открытый метод получения точек public Point Shape.GetVertex(int) мы бы не хотели, так как тогда этот массив или соответствующий метод оказались бы видны пользователям класса. Чего, в свою очередь, мы бы хотели избежать, так как наличие у класса круга открытого поля-массива вершин или метода, возвращающего вершину по индексу, было бы нелогично:


class Shape
{
  public Point[] vertices;
  public Point GetVertex(int i)
  {
    return vertices[i];
  }
}
class Circle : Shape
{
  public Point GetCenter()
  {
    // Обращение к полю базового класса.
    return vertices[0]
  }
}
Circle circle = new Circle();
Point center;
// Обе следующих строки выглядят нелогично и увеличивают сложность
// понимания программы,
// так как для получения центра круга мы вынуждены получать нулевую вершину.
center = cirle.vetices[0];
center = circle.GetVertex(0);
// Этот вариант выглядит естественно и не увеличивает сложность понимания.
center = circle.GetCenter();

Эта задача решается механизмом защищенных (protected) полей и методов.


class Shape
{
  protected Point[] vertices;
}
class Circle : Shape
{
  public Point GetCenter()
  {
    // Обращение к полю базового класса, допустимо, т.к. поле protected.
    return vertices[0]
  }
}
Circle circle = new Circle();
// Ошибка, так как защищенное поле protected не видно пользователям классов.
Point center = Circle.vertices[0];

Защищенные (protected) поля и методы – поля и методы, которые видны в методах класса, в котором они объявлены и в методах всех производных классов, но не видны извне этих классов.

Защищенные поля и методы помечаются модификатором видимости protected. В следующей таблицы приведена сводная информация по модификаторам видимости полей и методов:

Видимость: Из методов того же класса Из методов производных классов Из методов других (не производных) классов

private

(закрытые)

да нет нет

protected

(защищенные)

да да нет

public

(открытые)

да да да

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

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

§ 30. Многоуровневое наследование. В объектно-ориентированном программировании допускается многоуровневое наследование (multilevel): базовые классы могут быть производными по отношению к другим классам.

При многоуровневом наследовании открытые и защищенные поля и методы видны всем производным классам по иерархии наследования. Рассмотрим следующий пример с четырехуровневым наследованием:


class A { }
class B : A { protected int fieldB; }
class C : B { public int fieldC; }
class D1 : C { }
class D2 : C { }

Соответствующая диаграмма классов:

Поле fieldB класса B не видно в классе A, так как это базовый, а не производный класс, по отношению к B, но оно видно в классах C, D1 и D2, так как все они производные по отношению к классу B. Аналогично наследуется поле fieldC.

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

§ 31. Множественное наследование. До сих пор мы рассматривали ситуации, когда производный класс имеет строго один базовый класс. Однако можно представить, что класс является «частным» по отношению к нескольким «общим». Наследование класса от более чем одного другого класса называется множественным наследованием (multiple).

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

Класс D1 имеет два базовых класса: B и C. Для класса D1 метод Move() определен и в базовом классе B, и в базовом классе C. Это неизбежно усложняет логику использования класса D1. Класс A опосредованно наследуется как через B, так и через C, что также усложняет логику использования класса D1. Более детально мы не будем рассматривать этот вопрос. Множественное наследование поддерживается, например, в C++, но не поддерживается, помимо C#, в Java, Python и большинстве распространенных сегодня объектно-ориентированных языков.

§ 32. Механизм создания экземпляра производного класса. В примере с геометрическими фигурами в § 28 мы уже указали, что при создании объекта производного класса сначала вызывается конструктор базового класса, а потом – конструктор производного. Приведем рассмотренный фрагмент кода повторно:


class Shape
{
  protected Point[] vertices;
  public Shape(int n)
  {
    vertices = new Point[n];
  }
}
class Circle5 : Shape
{
  private float radius;
  public Circle5 () : base (1) 
  {
    radius = 1; 
  }
}
Circle5 circle = new Circle5();

Оператор new сначала определяет, какой конструктор базового класса Shape соответствует вызванному конструктору производного класса Circle5. Здесь мы вызвали конструктор по умолчанию класса Circle5, и в определении этого конструктора указано, что ему соответствует конструктор Shape(int) базового класса. Поэтому сначала вызывается конструктор Shape(int), а затем выполняется тело конструктора Circle5(). Однако такое описание не отвечает на следующие вопросы: 1) какова последовательность выполнения конструкторов при многоуровневом наследовании и 2) когда и в какой последовательности выполняется выделение памяти и инициализация полей классов?

Интуитивно понятно, что по аналогии с порядком вызовов конструкторов при одноуровневом наследовании, при многоуровневом наследовании конструкторы также выполняется в порядке от «самых базовых» к «самым производным» классам38:


class A
{
  public A()
  {
    Console.WriteLine (“A()”);
  }
  public A(int n)
  {
    Console.WriteLine (“A(int)”)
  }
}
class B : A
{
  public B() : base(0) 
  {
    Console.WriteLine (“B()”);
  } 
}
public class C : B 
{
  public class C() : base()
  {
    Console.WriteLine (“C()”);
  }
}
C c = new C();

При создании экземпляра класса C получим следующий вывод в консоль:


> A(int)
> B()
> C()

Обратим внимание, что в классе A объявлено два конструктора. При конструировании объекта класса C используется конструктор A(int), так как именно он указан как соответствующий конструктор базового класса для конструктора B(), который, в свою очередь, указан как соответствующий конструктор для конструктора C(), который и вызван оператором new. Проиллюстрируем этот механизм следующим рисунком:

Если следует использовать конструктор по умолчанию базового класса, то запись «: base()» в объявлении конструктора производного класса можно опускать:


public class C : B 
{
  // Так как конструктор базового класса явно не указан, то будет использован 
  // конструктор по умолчанию base().
  public class C()
  {
    Console.WriteLine (“C()”);
  }
}

Если указанный конструктор базового класса не существует, то это приведет к ошибке компиляции:


class D
{
  public D(int n)
  {
  }
}
class E : D
{
  // ОШИБКА компиляции:
  // В базовом классе D нет конструктора по умолчанию D().
  public E() 
  {    
  }
  // ОШИБКА компиляции:
  // В базовом классе D нет конструктора с сигнатурой D(int, int).
  public E() : base (0, 0)
  {
  } 
}

Также отметим, что конструкторы, как поля и методы, могут быть объявлены открытыми (public), закрытыми (private) или защищенными (protected). Пример использования закрытых конструкторов мы рассматривали в главе 2.6. Здесь же обратим внимание, что производный класс не может обратиться к закрытому конструктору базового класса, так же как он не может обращаться к закрытым полям и методам базового класса.

Теперь перейдем ко второму сформулированному нами вопросу: когда и в какой последовательности выделяется и инициализируется память для полей класса? При конструировании объекта производного класса память для полей класса выделяется и инициализируется в порядке от «самых производных» к «самым базовом», то есть в порядке, обратном порядку вызова конструкторов. Разберем это на следующем примере:


class Class1
{
  private string msg;
  public Class1(string msg)
  {
    this.msg = msg;
    Console.WriteLine("Class1 constructor: " + msg);
  }
}
class Class2
{
  private Class1 class1 = new Class1("from Class2");
  public Class2()
  {
    Console.WriteLine("Class2 constructor");
  }
}
class Class3 : Class2
{
  private Class1 class1 = new Class1("from Class3");  
  public Class3 ()
  {
    Console.WriteLine("Class3 constructor");
  }
}
class Class4 : Class3
{
  private Class1 class1 = new Class1("from Class4");
  public Class4 ()
  {
    Console.WriteLine("Class4 constructor");
  }
}
// Создание экземлпяра производного класса.
Class4 class4 = new Class4();

При создании экземпляра класса Class4 сначала инициализируются поля всех классов от «самых производных» к «самым базовым», то есть от Class4 к Class2, потом вызываются конструкторы от «самых базовых» к «самым производным», то есть от Class2 к Class4. Проследите этот процесс пошагово:


> Class1 constructor: from Class4
> Class1 constructor: from Class3
> Class1 constructor: from Class2
> Class2 constructor
> Class3 constructor
> Class4 constructor

В результате получим следующее распределение памяти:

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

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


class A
{
  protected int n = 5;
  public A ()
  {
    n = 10;
  }
}
class B : A
{
  // ОШИБКА компиляции, так как переменная n базового класса еще не существует 
  //  в момент вызова инициализатора new Polygon (n).
  protected Polygon p = new Polygon (n);
  public B ()
  {
    // Здесь верно, так как при вызове конструктора 
    // уже инициализированы все поля
    // всех базовых классов в иерархии наследования и 
    // выполнены все конструкторы. Также, обратите внимание, что здесь n == 10.
    p = new Polygon (n);
  } 
}

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

  1. Для всех классов в иерархии наследования в порядке от «самого производного» к «самому базовому»: для всех полей класса выделяется память и все поля класса инициализируются или указанными явно значениями, или значениями по умолчанию для соответствующего типа данных.

  2. Определяется цепочка конструкторов в иерархии наследования для вызванного конструктора производного класса. Конструкторы цепочки выполняются последовательно в порядке от «самого базового» к «самому производному».

  3. Последний выполнившийся конструктор возвращает адрес созданного объекта.

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

Вопросы и задания

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

  1. Дайте определения следующим терминам, а также сопоставьте русские и английские термины: агрегация, наследование, базовый класс, производный класс, суперкласс, подкласс, защищенные поля, методы и конструкторы, многоуровневое наследование, множественное наследование; aggregation, inheritance, base class, derived class, superclass, subclass, protected field, methods and constructors, multilevel inheritance, multiple inheritance.

  2. Наследуются ли конструкторы классов?

  3. Какой модификатор видимости имеет ключевое слово base? Допустимо ли обращение к базовому классу базового класса через выражение base.base?

  4. Что будет выведено в консоль после выполнения следующего кода:


class Item
{
  public Item(string message)
  {
    Console.WriteLine(“Item constructor: “ + message);
  }
}
class A
{
  protected Item itemA = new Item(“from A init”);
  protected A()
  {
    itemA = new Item (“from A.A()”);
  }
}
class B
{
  protected Item itemB; 
}
class C
{
  protected Item itemC = new Item (“from C init”);
  public C() : this (“from C.C() this”)
  {
    itemC = new Item (“from C.C()”);
    itemB = new Item (“from C.C()”);
  }
  public C(string message)
  {
    itemC = new Item (message);
  }
}
C object1 = new C();
C object2 = new C(“object2”);

Прежде чем пробовать запустить код, имеет смысл «выполнить» его на бумаге.

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


Примечания:

  1. 37.  Строго говоря, в объектном анализе принято выделять два подвида отношений «часть – целое»: агрегацию и композицию (composition). Агрегация обозначает отношение, при котором «часть» может (семантически) существовать независимо от «целого». Например, в рассматриваемом примере фигура Shape (по сути – массив точек) вполне может существовать сама по себе, не будучи частью классов Triangle, Polygon или Circle. В случае же композиции часть существует исключительно в составе целого. Классический пример: отношение между классами «дом» House и «комната» Room. Комната не может существовать без дома (в реальном мире). Однако здесь и далее мы не будем проводить отличия между этими подвидами, упрощенно называя все отношения «часть – целое» агрегацией.

    ↩︎
  2. 38.  Мы уже указывали, что в практике ООП глубина наследования ограничивается одним-двумя уровнями. Однако в настоящей главе в учебных целях мы неоднократно будем обращаться к примерам с глубоким многоуровневым наследованием, чтобы продемонстрировать те или иные аспекты наследования.

    ↩︎

О сайте, об авторе, контакты, оставить отзыв.

Телеграм-канал: newobjx.

© Тимофей Усов, 2019—2020.