Опубликован: 15.09.2010 | Доступ: свободный | Студентов: 4885 / 651 | Оценка: 3.97 / 3.80 | Длительность: 14:45:00
Лекция 8:

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

< Лекция 7 || Лекция 8: 123 || Лекция 9 >
Аннотация: Организация иерархий классов. Раннее и позднее связывание. Виртуальные методы. Абстрактные и бесплодные классы. Виды взаимоотношений между классами.

Презентацию к данной лекции Вы можете скачать здесь.

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

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

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

Описание класса-потомка

Класс в C# может иметь произвольное количество потомков и только одного предка. При описании класса имя его предка записывается в заголовке класса после двоеточия. Если имя предка не указано, предком считается базовый класс всей иерархии System.Object:

[ атрибуты ] [ спецификаторы ] class имя_класса [ : предки ]
    тело класса

Примечание

Обратите внимание, что слово "предки" присутствует в описании класса во множественном числе, хотя класс может иметь только одного предка. Причина в том, что класс наряду с единственным предком может наследовать от интерфейсов — специального вида классов, не имеющих реализации. Интерфейсы рассматриваются на следующей лекции.

Рассмотрим наследование классов на примере. Ранее был описан класс Monster, моделирующий персонаж компьютерной игры. Допустим, нам требуется ввести в игру еще один тип персонажей, который должен обладать свойствами объекта Monster, а кроме того, уметь думать. Будет логично сделать новый объект потомком объекта Monster (листинг 8.11).

using System;
namespace ConsoleApplication1
{
    class Monster
    {
        ... 
    }
    
    class Daemon : Monster
    {
        public Daemon()
        {
            brain = 1;
        }

        public Daemon( string name, int brain ) : base( name )          // 1
        {
            this.brain = brain;
        }

        public Daemon( int health, int ammo, string name, int brain ) 
            : base( health, ammo, name )                                // 2
        {
            this.brain = brain;
        }

        new public void Passport()                                      // 3
        {
            Console.WriteLine(
                "Daemon {0} \t health = {1} ammo = {2} brain = {3}", 
                Name, Health, Ammo, brain );
        }

        public void Think()                                             // 4
        {
            Console.Write( Name + " is" );
            for ( int i = 0; i < brain; ++i ) Console.Write( " thinking" );
            Console.WriteLine( "..." );
        }

        int brain;        // закрытое поле
    }

    class Class1
    {   static void Main()
        {
            Daemon Dima = new Daemon( "Dima", 3 );                      // 5
            Dima.Passport();                                            // 6
            Dima.Think();                                               // 7
            Dima.Health -= 10;                                          // 8
            Dima.Passport();
        }
    }
}
Листинг 8.1. Класс Daemon, потомок класса Monster

В классе Daemon введены закрытое поле brain и метод Think, определены собственные конструкторы, а также переопределен метод Passport. Все поля и свойства класса Monster наследуются в классе Daemon.

Результат работы программы:

Daemon Dima      health = 100 ammo = 100 brain = 3
Dima is thinking thinking thinking...
Daemon Dima      health = 90 ammo = 100 brain = 3

Как видите, экземпляр класса Daemon с одинаковой легкостью использует как собственные (операторы 5–7), так и унаследованные (оператор 8) элементы класса. Рассмотрим общие правила наследования.

Конструкторы не наследуются, поэтому производный класс должен иметь собственные конструкторы. Порядок вызова конструкторов определяется приведенными ниже правилами.

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

Поля, методы и свойства класса наследуются, поэтому при желании заменить элемент базового класса новым элементом следует явным образом указать компилятору свое намерение с помощью ключевого слова new. В листинге 8.1 таким образом переопределен метод вывода информации об объекте Passport. Метод Passport класса Daemon замещает соответствующий метод базового класса, однако возможность доступа к методу базового класса из метода производного класса сохраняется. Для этого перед вызовом метода указывается все то же волшебное слово base, например:

base.Passport();

Элементы базового класса, определенные как private, в производном классе недоступны. Поэтому в методе Passport для доступа к полям name, health и ammo пришлось использовать соответствующие свойства базового класса. Другое решение заключается в том, чтобы определить эти поля со спецификатором protected, в этом случае они будут доступны методам всех классов, производных от Monster. Оба решения имеют свои достоинства и недостатки.

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

Попробуем описать массив объектов базового класса и занести туда объекты производного класса. В листинге 8.2 в массиве типа Monster хранятся два объекта типа Monster и один — типа Daemon.

using System;
namespace ConsoleApplication1
{
    class Monster
    {
        ... 
    }
    
    class Daemon : Monster
    {
        ... //
    }

    class Class1
    {   static void Main()
        {
            const int n = 3;
            Monster[] stado = new Monster[n];

            stado[0] = new Monster( "Monia" );
            stado[1] = new Monster( "Monk" );
            stado[2] = new Daemon ( "Dimon", 3 );

            foreach ( Monster elem in stado ) elem.Passport();          // 1

            for ( int i = 0; i < n; ++i ) stado[i].Ammo = 0;            // 2
            Console.WriteLine();

            foreach ( Monster elem in stado ) elem.Passport();          // 3
        }
    }
}
Листинг 8.2. Массив объектов разных типов

Результат работы программы:

Monster Monia    health = 100 ammo = 100
Monster Monk     health = 100 ammo = 100
Monster Dimon    health = 100 ammo = 100

Monster Monia    health = 100 ammo = 0
Monster Monk     health = 100 ammo = 0
Monster Dimon    health = 100 ammo = 0

Результат радует нас только частично: объект типа Daemon действительно можно поместить в массив, состоящий из элементов типа Monster, но для него вызываются только методы и свойства, унаследованные от предка. Это устраивает нас в операторе 2, а в операторах 1 и 3 хотелось бы, чтобы вызывался метод Passport, переопределенный в потомке.

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

Это и понятно: ведь компилятор должен еще до выполнения программы решить, какой метод вызывать, и вставить в код фрагмент, передающий управление на этот метод (этот процесс называется ранним связыванием ). При этом компилятор может руководствоваться только типом переменной, для которой вызывается метод или свойство (например, stado[i].Ammo ). То, что в этой переменной в разные моменты времени могут находиться ссылки на объекты разных типов, компилятор учесть не может.

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

Виртуальные методы

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

Следовательно, надо каким-то образом дать знать компилятору, что эти методы будут обрабатываться по-другому. Для этого в C# существует ключевое слово virtual. Оно записывается в заголовке метода базового класса, например:

virtual public void Passport() ...

Объявление метода виртуальным означает, что все ссылки на этот метод будут разрешаться в момент его вызова во время выполнения программы. Этот механизм называется поздним связыванием.

Для его реализации необходимо, чтобы адреса виртуальных методов хранились там, где ими можно будет в любой момент воспользоваться, поэтому компилятор формирует для этих методов таблицу виртуальных методов (Virtual Method Table, VMT). В нее записываются адреса виртуальных методов (в том числе унаследованных) в порядке описания в классе. Для каждого класса создается одна таблица.

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

Если в производном классе требуется переопределить виртуальный метод, используется ключевое слово override, например:

override public void Passport() ...

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

Добавим в листинге 8.2 два волшебных слова — virtual и override — в описания методов Passport соответственно базового и производного классов (листинг 8.3).

using System;
namespace ConsoleApplication1
{
    class Monster
    {
        ...
        virtual public void Passport()
        {
            Console.WriteLine( "Monster {0} \t health = {1} ammo = {2}", 
                              name, health, ammo );
        }
        ...
    }
    
    class Daemon : Monster
    {
        ...
        override public void Passport()
        {
            Console.WriteLine(
                "Daemon {0} \t health = {1} ammo = {2} brain = {3}", 
                Name, Health, Ammo, brain );
        }
        ...
    }

    class Class1
    {   static void Main()
        {
            const int n = 3;
            Monster[] stado = new Monster[n];

            stado[0] = new Monster( "Monia" );
            stado[1] = new Monster( "Monk" );
            stado[2] = new Daemon ( "Dimon", 3 );

            foreach ( Monster elem in stado ) elem.Passport();

            for ( int i = 0; i < n; ++i ) stado[i].Ammo = 0;
            Console.WriteLine();

            foreach ( Monster elem in stado ) elem.Passport();
        }
    }
}
Листинг 8.3. Виртуальные методы

Результат работы программы:

Monster Monia    health = 100 ammo = 100
Monster Monk     health = 100 ammo = 100
Daemon Dimon     health = 100 ammo = 100 brain = 3

Monster Monia    health = 100 ammo = 0
Monster Monk     health = 100 ammo = 0
Daemon Dimon     health = 100 ammo = 0 brain = 3

Теперь в циклах 1 и 3 вызывается метод Passport, соответствующий типу объекта, помещенного в массив.

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

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

Виртуальные методы незаменимы и при передаче объектов в методы в качестве параметров. В параметрах метода описывается объект базового типа, а при вызове в нее передается объект производного класса. Виртуальные методы, вызываемые для объекта из метода, будут соответствовать типу аргумента, а не параметра.

< Лекция 7 || Лекция 8: 123 || Лекция 9 >
Надив Чаторов
Надив Чаторов
Россия
Константан Праурзин
Константан Праурзин
Россия