Компонентный подход в программировании

       

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


Отношение вложенности между типами определяется наследованием. Обычно говорят, что класс наследует другому классу или является его потомком, наследником, если он определяет более узкий тип, т.е. все объекты этого класса являются также и объектами наследуемого им. Второй класс в этом случае называют предком первого. Взаимоотношения между интерфейсами описываются в тех же терминах, но вместо "класс наследует интерфейсу" обычно говорят, что класс реализует интерфейс.

В обоих языках класс может наследовать только одному классу и реализовывать несколько интерфейсов. Интерфейс может наследовать многим интерфейсам.

Классы, которые не должны иметь наследников, помечаются в Java как final, а в C# — как sealed.

В Java все классы (но не интерфейсы!) считаются наследниками класса java.lang.Object.

Примитивные типы не являются его наследниками, в отличие от своих классов-оберток.

В C# все классы, структурные, перечислимые и делегатные типы (но не интерфейсы!) рассматриваются как наследники класса System.Object, на который обычно ссылаются как на object.

При этом, однако, типы значений (перечислимые и структурные типы, наследники System.ValueType) преобразуются к типу object с помощью упаковки, строящей каждый раз новый объект.

Структурный тип может реализовывать один или несколько интерфейсов, но не может наследовать классу или другому структурному типу.

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

Если же необходимо получить доступ к соответствующему полю предка, нужно использовать разные подходы в зависимости от того, статическое это поле или нет, т.е. относится ли оно к самому классу или к его объектам. К статическому полю можно обратиться, указав его полное имя, т.е.
ClassName.fieldName, к нестатическому полю из кода класса-потомка можно обратиться с помощью конструкций super.fieldName в Java и base.fieldName в C# (естественно, если оно не перекрыто в каком-то классе, промежуточном между данными предком и потомком). Конструкции super в Java и base в C# можно использовать и для обращения к операциям, декларированным в предке данного класса. Для обращения к полям и операциям самого объекта в обоих языках можно использовать префикс this, являющийся ссылкой на объект, в котором вызывается данная операция.

Основная выгода от использования наследования — возможность перегружать (override) реализации операций в типах-наследниках. Это значит, что при вызове операции с данной сигнатурой в объекте наследника может быть выполнена не та реализация этой операции, которая определена в предке, а совсем другая, определенная в точном типе объекта. Такие операции называют виртуальными (virtual). Чтобы определить новую реализацию некоторой виртуальной операции предка в потомке, нужно определить в потомке операцию с той же сигнатурой. При этом необходимо следовать общему принципу, обеспечивающему корректность системы типов в целом — принципу подстановки (Liskov substitution principle) [4,5]. Поскольку тип-наследник является более узким, чем тип -предок, его объект может использоваться всюду, где может использоваться объект типа-предка. Принцип подстановки, обеспечивающий это свойство, требует соблюдения двух правил:

  • Во всякой ситуации, в которой можно вызвать данную операцию в предке, ее вызов должен быть возможен и в наследнике. Говоря по-другому, предусловие операции при перегрузке не должно усиливаться.
  • Множество ситуаций, в которых система в целом может оказаться после вызова операции в наследнике, должно быть подмножеством набора ситуаций, в которых она может оказаться в результате вызова этой операции в предке. То есть постусловие операции при перегрузке не должно ослабляться.


Статические операции, относящиеся к классу в целом, а не к его объектам, не виртуальны.


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





В Java все нестатические методы классов являются виртуальными, т.е. перегружаются при определении метода с такой же сигнатурой в классе-потомке.

Но в Java, в отличие от C#, можно вызывать статические методы и обращаться к статическим полям класса через ссылки на его объекты (в том числе, и через this). Поэтому работу невиртуальных методов можно смоделировать с помощью обращений к статическим операциям.


В C# нестатические операции (обычные методы и методы доступа к свойствам, индексерам и событиям) могут быть как виртуальными, т.е. перегружаемыми, так и невиртуальными.

Для декларации виртуального метода (свойства, индексера или события) необходимо пометить его модификатором virtual. Метод (свойство, индексер, событие), перегружающий его в классе-потомке надо в этом случае пометить модификатором override. Если же мы не хотим перегружать элемент предка, а хотим определить новый элемент с такой же сигнатурой (т.е. перекрыть старый), то его надо пометить модификатором new.

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



class A { public void m() { System.out.println("A.m() called"); }

public static void n() { System.out.println("A.n() called"); } }

class B extends A { public void m() { System.out.println("B.m() called"); }

public static void n() { System.out.println("B.n() called"); } }

public class C { public static void main(String[] args) { A a = new A(); B b = new B(); A c = new B();

a.m(); b.m(); c.m(); System.out.println("-----"); a.n(); b.n(); c.n(); } }
using System;

class A { public virtual void m() { Console.WriteLine("A.m() called"); }

public void n() { Console.WriteLine("A.n() called"); } }

class B : A { public override void m() { Console.WriteLine("B.m() called"); }

public new void n() { Console.WriteLine("B.n() called"); } }

public class C { public static void Main() { A a = new A(); B b = new B(); A c = new B();

a.m(); b.m(); c.m(); Console.WriteLine("-----"); a.n(); b.n(); c.n(); } }


Представленный в примере код выдает следующие результаты.

A.m() called B.m() called B.m() called ----- A.n() called B.n() called A.n() called


Если в приведенном примере убрать модификатор new у метода n() в классе B, ошибки компиляции не будет, но будет выдано предупреждение о перекрытии имен, возможно случайном.

Представленный в примере код выдает следующие результаты.

A.m() called B.m() called B.m() called ----- A.n() called B.n() called A.n() called

Содержание раздела