Глава 7

Классы: подробности

 

В этой главе мы продолжим знакомство с элементами классов, начатое в главе 5. Сначала мы рассмотрим дополнительные возможности методов, не описанные в главе 5, а затем перейдем к новым элементам класса — индексаторам, операци­ям и деструкторам.

 

Перегрузка методов

 

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

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------Мы уже использовали перегруженные версии методов стандартных классов и даже сами создавали перегруженные методы, когда описывали в классе несколько конструкторов.

---------------------------------------------------------------------------------------------------------------------

 

Компилятор определяет, какой именно метод требуется вызвать, по типу факти­ческих параметров. Этот процесс называется разрешением (resolution) перегруз­ки. Тип возвращаемого методом значения в разрешении не участвует. Механизм разрешения основан на достаточно сложном наборе правил, смысл которых сво­дится к тому, чтобы использовать метод с наиболее подходящими аргументами и выдать сообщение, если такой не найдется. Допустим, имеется четыре варианта метода, определяющего наибольшее значение:

 

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

Если точного соответствия не найдено, выполняются неявные преобразова­ния типов в соответствии с общими правилами, например, bool и char в int, float в doublе и т. п. Если преобразование невозможно, выдается сообщение об ошиб­ке. Если соответствие на одном и том же этапе может быть получено более чем одним способом, выбирается «лучший» из вариантов, то есть вариант, содержа­щий меньшие количество и длину преобразований в соответствии с правилами, описанными в разделе «Преобразования встроенных арифметических типов-зна­чений» (см. с. 45). Если существует несколько вариантов, из которых невозмож­но выбрать лучший, выдается сообщение об ошибке.

Вам уже известно, что все методы класса должны различаться сигнатурами. Это понятие было введено в разделе «Методы» (см. с. 106). Перегруженные методы имеют одно имя, но должны различаться параметрами, точнее, их типами и спо­собами передачи (out или ref). Например, методы, заголовки которых приведены ниже, имеют различные сигнатуры и считаются перегруженными:

 

  int max( int a, int b )

 int max( int a, ref int b )

 

Перегрузка методов является проявлением полиморфизма, одного из основных свойств ООП. Программисту гораздо удобнее помнить одно имя метода и ис­пользовать его для работы с различными типами данных, а решение о том, какой вариант метода вызвать, возложить на компилятор. Этот принцип широко ис­пользуется в классах библиотеки .NET. Например, в стандартном классе Console метод WriteLine перегружен 19 раз для вывода величин разных типов.

 

Рекурсивные методы

 

Рекурсивным называется метод, который вызывает сам себя. Такая рекурсия назы­вается прямой. Существует еще косвенная рекурсия, когда два или более метода вызывают друг друга. Если метод вызывает себя, в стеке создается копия значений его параметров, как и при вызове обычного метода, после чего управление пере­дается первому исполняемому оператору метода. При повторном вызове этот процесс повторяется.

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

Классическим примером рекурсивной функции является функция вычисления факториала (это не означает, что факториал следует вычислять именно так). Для того чтобы получить значение факториала числа я, требуется умножить на п фак­ториал числа (п - 1). Известно также, что 0 != 1 и 1 != 1:

 

 

Рекурсивные методы чаще всего применяют для компактной реализации рекур­сивных алгоритмов, а также для работы со структурами данных, описанными ре­курсивно, например, с двоичными деревьями (понятие о двоичном дереве дается в главе 13). Любой рекурсивный метод можно реализовать без применения ре­курсии, для этого программист должен обеспечить хранение всех необходимых данных самостоятельно.

К достоинствам рекурсии можно отнести компактность записи, к недостат­кам — расход времени и памяти на повторные вызовы метода и передачу ему ко­пий параметров, а главное, опасность переполнения стека.

 

Методы с переменным количеством аргументов

 

Иногда бывает удобно создать метод, в который можно передавать разное ко­личество аргументов. Язык С# предоставляет такую возможность с помощью ключевого слова params. Параметр, помеченный этим ключевым словом, раз­мещается в списке параметров последним и обозначает массив заданного типа неопределенной длины, например:

 

public int Calculate( int a, out int с params int[ ] d ) ...

 

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

элементов массива получают с помощью его свойства Length. В качестве примера рассмотрим метод вычисления среднего значения элементов массива (листинг 7.1).

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------

В данном алгоритме отсутствие аргументов при вызове метода Average является ошибкой. Этот случай обрабатывается генерацией исключения. Если не обработать эту ошибку, результат вычисления среднего будет равен «не числу» (NaN) вследст­вие деления на ноль в операторе возврата из метода.

 

Параметр-массив может быть только один и должен располагаться последним в списке. Соответствующие ему аргументы должны иметь типы, для которых воз­можно неявное преобразование к типу массива.

 

Метод Main

 

Метод, которому передается управление после запуска программы, должен иметь имя Main и быть статическим. Он может принимать параметры из внешнего окру­жения и возвращать значение в вызвавшую среду. Предусматривается два вари­анта метода — с параметрами и без параметров:

 

 

Параметры, разделяемые пробелами, задаются при запуске программы из ко­мандной строки после имени исполняемого файла программы. Они передаются в массив args.

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------

Имя параметра в программе может быть любым, но принято использовать имя args.

--------------------------------------------------------------------------------------------------------------------

Если метод возвращает значение, оно должно быть целого типа, если не возвращает, он должен описываться как void. В этом случае оператор возврата из Main можно опускать, а вызвавшая среда автоматически получит нулевое значение, означающее успешное завершение. Ненулевое значение обычно означает аварий­ное завершение, например:

 

 

Возвращаемое значение анализируется в командном файле, из которого запускает­ся программа. Обычно это делается для того, чтобы можно было принять решение, выполнять ли командный файл дальше. В листинге 7.2 приводится пример мето­да Main, который выводит свои аргументы и ожидает нажатия любой клавиши.

Пусть исполняемый файл программы имеет имя ConsoleApplicationi .exe и вызы­вается из командной строки:

 

d:\cs\ConsoleApplicationl\bin\Debug\ConsoleApplicationl.exe one two three

 

 Тогда на экран будет выведено:

one

two                                                                                               

three

 

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

 

ПРИМЕЧАНИЕ ------------------------------------------------------------------------------------------

Для запуска программы из командной строки можно воспользоваться, к примеру, ко­мандой Выполнить меню Пуск или командой Пуск Программы ► Стандартные ► Командная строка.

 

 

Индексаторы

 

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

Синтаксис индексатора аналогичен синтаксису свойства:

 

атрибуты спецификаторы тип this [ список_параметров ]

{

            get код_доступа

            set код_доступа

}

 

ВНИМАНИЕ--------------------------------------------------------------------------------------------------

В данном случае квадратные скобки являются элементом синтаксиса, а не указани­ем на необязательность конструкции.

--------------------------------------------------------------------------------------------------------------------

Атрибуты мы рассмотрим позже, в главе 12, а спецификаторы аналогичны спецификаторам свойств и методов. Индексаторы чаще всего объявляются со

спецификатором public, поскольку они входят в интерфейс объекта. Атрибуты и спецификаторы могут отсутствовать.

Код доступа представляет собой блоки операторов, которые выполняются при получении (get) или установке значения (set) элемента массива. Может отсутст­вовать либо часть get, либо set, но не обе одновременно. Если отсутствует часть set, индексатор доступен только для чтения (read-only), если отсутствует часть get, индексатор доступен только для записи (write-only).

Список параметров содержит одно или несколько описаний индексов, по кото­рым выполняется доступ к элементу. Чаще всего используется один индекс це­лого типа.

Индексаторы в основном применяются для создания специализированных мас­сивов, на работу с которыми накладываются какие-либо ограничения. В листин­ге 7.3 создан класс-массив, элементы которого должны находиться в диапазоне [О, 100]. Кроме того, при доступе к элементу проверяется, не вышел ли индекс за допустимые границы.

 

 

 

Из листинга видно, что индексаторы описываются аналогично свойствам. Благо­даря применению индексаторов с объектом, заключающим в себе массив, можно работать так же, как с обычным массивом. Если обращение к объекту встречает­ся в левой части оператора присваивания (оператор 1), автоматически вызывает­ся метод get. Если обращение выполняется в составе выражения (оператор 2), вызывается метод set.

В классе SafeArray принята следующая стратегия обработки ошибок: если при попытке записи элемента массива его индекс или значение заданы неверно, зна­чение элементу не присваивается; если при попытке чтения элемента индекс не входит в допустимый диапазон, возвращается 0; в обоих случаях формируется значение открытого поля error, равное true.

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------

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

------------------------------------------------------------------------------------------------------------------

Вообще говоря, индексатор не обязательно должен быть связан с каким-либо внутренним полем данных. В листинге 7.4 приведен пример класса Pow2, единст­венное назначение которого — формировать степень числа 2.

 

 

 

Оператор 1 выполняется в непроверяемом контексте, для того чтобы исключе­ние, связанное с переполнением, не генерировалось. В принципе, данная програм­ма работает и без этого, но если поместить класс Pow2 в проверяемый контекст, при значении, превышающем допустимый диапазон для типа ulong, возникнет исключение.

 

 

 

Язык (J# допускает использование многомерных индексаторов. Они описывают­ся аналогично обычным и применяются в основном для контроля за занесением данных в многомерные массивы и выборке данных из многомерных массивов, оформленных в виде классов. Например:

 

int[.] a;

 

Если внутри класса объявлен такой двумерный массив, то заголовок индексато­ра должен иметь вид                                        

 

public int this[int i, int j]

 

Операции класса

 

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

 

MyObject a. b, с:

….

 

с = а + b;                    // используется операция сложения для класса MyObject

 

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

Операции класса описываются с помощью методов специального вида (функ­ций-операций). Перегрузка операций похожа на перегрузку обычных методов. Синтаксис операции:

 

[ атрибуты ] спецификаторы объявитель_операции тело

 

Атрибуты рассматриваются в главе 12, в качестве спецификаторов одновремен­но используются ключевые слова publiс и statiс. Кроме того, операцию можно объявить как внешнюю (extern).

Объявитель операции содержит ключевое слово operator, по которому и опозна­ется описание операции в классе. Тело операции определяет действия, которые выполняются при использовании операции в выражении. Тело представляет со­бой блок, аналогичный телу других методов.

 

ВНИМАНИЕ --------------------------------------------------------------------------------------------------

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

_____________________________________________________________________________

При описании операций необходимо соблюдать следующие правила:

□  операция должна быть описана как открытый статический метод класса (спе­цификаторы public static);

□  параметры в операцию должны передаваться по значению (то есть не должны предваряться ключевыми словами ref или out);

□  сигнатуры всех операций класса должны различаться;

□  типы, используемые в операции, должны иметь не меньшие права доступа, чем сама операция (то есть должны, быть доступны при использовании операции).

В С# существуют три вида операций класса: унарные, бинарные и операции преобразования типа.                                                                                                

 

Унарные операции                                                      

 

Можно определять в классе следующие унарные операции:

+        -         !        ~        ++        --        true        false

 

Синтаксис объявителя унарной операции:                                                           

 

тип operator унарная_операция ( параметр )

 

Примеры заголовков унарных операций:                                                            

 

public static int operator +( MyObject m )                                                                        

public static MyObject operator --( MyObject m )                                                                public static bool operator true( MyObject m )                                                                   

 

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

 

□  для операций +, -, ! и ~ величину любого типа;

□ для операций ++ и - - величину типа класса, для которого она определяется;

□  для операций true и false величину типа bool.

 

Операции не должны изменять значение передаваемого им операнда. Операция,  

возвращающая величину типа класса, для которого она определяется, должна 

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

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------Префиксный и постфиксный инкременты не различаются (для них может сущест­вовать только одна реализация, которая вызывается в обоих случаях).

--------------------------------------------------------------------------------------------------------------------    

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------Операции true и false обычно определяются для логических типов SQL, обладаю­щих неопределенным состоянием, и не входят в число тем, рассматриваемых в этой книге.

---------------------------------------------------------------------------------------------------------------------

В качестве примера усовершенствуем приведенный в листинге 7.3 класс SafeArray для удобной и безопасной работы с массивом. В класс внесены следующие изме­нения:

 

□  добавлен конструктор, позволяющий инициализировать массив обычным мас­сивом или серией целочисленных значений произвольного размера;

□  добавлена операция инкремента;

□  добавлен вспомогательный метод Print вывода массива;

□  изменена стратегия обработки ошибок выхода за границы массива;

□  снято требование, чтобы элементы массива принимали значения в заданном диапазоне.

 

Текст программы приведен в листинге 7.5.        

   

 

 

 

 

 

Бинарные операции

 

Можно определять в классе следующие бинарные операции:

 

+     -     *     /     %     &      |      ^     «»   ==    !=    >     <    >=      <=

 

ВНИМАНИЕ -------------------------------------------------------------------------------------------------

Операций присваивания в этом списке нет.

 

 

Синтаксис объявителя бинарной операции:

 

тип operator бинарная_операция (параметр1., паранетр2)

 

Примеры заголовков бинарных операций:

 

public static MyObject operator +   ( MyObject ml. MyObject m2 )

 public static bool         operator == ( MyObject ml. MyObject m2 )

 

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

Операции == и ! =, > и <, >= и <= определяются только парами и обычно возвраща­ют логическое значение. Чаще всего в классе определяют операции сравнения на равенство и неравенство для того, чтобы обеспечить сравнение объектов, а не их ссылок, как определено по умолчанию для ссылочных типов. Перегрузка опера­ций отношения требует знания интерфейсов, поэтому она рассматривается поз­же, в главе 9 (см. с. 203).

Пример определения операции сложения для класса SafeArray, описанного в пре­дыдущем разделе, приведен в листинге 7.6. В зависимости от операндов опера­ция либо выполняет поэлементное сложение двух массивов, либо прибавляет значение операнда к каждому элементу массива.

 

 

 

 

 

 

 

 

Обратите внимание: чтобы обеспечить возможность сложения с константой, опе­рация сложения перегружена два раза для случаев, когда константа является первым и вторым операндом (операторы 2 и 1).

Сложную операцию присваивания += (оператор 3) определять не требуется, да это и невозможно. При ее выполнении автоматически вызываются сначала операция

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

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------В перегруженных методах для объектов применяется индексатор. Для повышения эффективности можно обратиться к закрытому полю-массиву и непосредственно,

например: temp.a[i] = х + y.a[i].

------------------------------------------------------------------------------------------------------------------

 

 

 

 

Операции преобразования типа

 

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

 

implicit operator тип ( параметр )             // неявное преобразование

explicit operator тип ( параметр )             // явное преобразование

 

Эти операции выполняют преобразование из типа параметра в тип, указанный в заголовке операции. Одним из этих типов должен быть класс, для которого определяется операция. Таким образом, операции выполняют преобразование либо типа класса к другому типу, либо наоборот. Преобразуемые типы не долж­ны быть связаны отношениями наследования. Примеры операций преобразова­ния типа для класса Monster, описанного в главе 5:

 

 

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

 

 

Неявное преобразование выполняется автоматически:

□  при присваивании объекта переменной целевого типа, как в примере;

□   при использовании объекта в выражении, содержащем переменные целевого типа;

□   при передаче объекта в метод на место параметра целевого типа;

□  при явном приведении типа.

 

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

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

 

 

 

 

Деструкторы

 

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

Синтаксис деструктора:

 

[ атрибуты ] [ extern ] ~имя_класса()

тело

 

Как видно из определения, деструктор не имеет параметров, не возвращает зна­чения и не требует указания спецификаторов доступа. Его имя совпадает с име­нем класса и предваряется тильдой (~), символизирующей обратные по отноше­нию к конструктору действия. Тело деструктора представляет собой блок или просто точку с запятой, если деструктор определен как внешний (extern). Сборщик мусора удаляет объекты, на которые нет ссылок. Он работает в соот­ветствии со своей внутренней стратегией в неизвестные для программиста моменты времени. Поскольку деструктор вызывается сборщиком мусора, не­возможно гарантировать, что деструктор будет обязательно вызван в процессе работы программы. Следовательно, его лучше использовать только для гарантии освобождения ресурсов, а «штатное» освобождение выполнять в другом месте программы. Применение деструкторов замедляет процесс сборки мусора.

 

Вложенные типы

 

В классе можно определять типы данных, внутренние по отношению к классу. Так определяются вспомогательные типы, которые используются только содержа­щим их классом. Механизм вложенных типов позволяет скрыть ненужные детали

и более полно реализовать принцип инкапсуляции. Непосредственный доступ извне к такому классу невозможен (имеется в виду доступ по имени без уточ­нения). Для вложенных типов можно использовать те же спецификаторы, что и для полей класса.

Например, введем в наш класс Monster вспомогательный класс Gun. Объекты этого класса без «хозяина» бесполезны, поэтому его можно определить как внут­ренний:       

 

     

 

Помимо классов вложенными могут быть и другие типы данных: интерфейсы, структуры и перечисления. Мы рассмотрим их в главе 9.

 

 

 

 

Рекомендации по программированию

 

Как правило, класс как тип, определенный пользователем, должен содержать скрытые (private) поля и следующие функциональные элементы:

□  конструкторы, определяющие, как инициализируются объекты класса;

□  набор методов и свойств, реализующих характеристики класса;

□   классы исключений, используемые для сообщений об ошибках путем генера­ции исключительных ситуаций.

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

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

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

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

Метод с переменным числом параметров реализуются менее эффективно, чем обычные, поэтому если, к примеру, требуется передавать в метод два, три или четыре параметра, возможно, окажется более эффективным реализовать не один метод с параметром params, а три перегруженных варианта с обычными пара­метрами.

                            

Глава 8

Иерархии классов

 

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

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

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

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

□  исключения из программы повторяющихся фрагментов кода;

□  упрощения модификации программы;

□  упрощения создания новых программ на основе существующих.

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

 

 

 

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

 

Класс в С# может иметь произвольное количество потомков и только одного предка. При описании класса имя его предка записывается в заголовке класса

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

 

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

тело класса

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------

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

---------------------------------------------------------------------------------------------------------------------

Рассмотрим наследование классов на примере. В разделе «Свойства» (см. с. 120) был описан класс Monster, моделирующий персонаж компьютерной игры. Допус­тим, нам требуется ввести в игру еще один тип персонажей, который должен об­ладать свойствами объекта Monster, а кроме того уметь думать. Будет логично сделать новый объект потомком объекта Monster (листинг 8.1).

 

 

 

 

 

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

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

 

 

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

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

□  Если в конструкторе производного класса явный вызов конструктора базового класса отсутствует, автоматически вызывается конструктор базового класса без параметров. Это правило использовано в первом из конструкторов класса Daemon.

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

□  Если конструктор базового класса требует указания параметров, он должен быть явным образом вызван в конструкторе производного класса в списке ини­циализации (это продемонстрировано в конструкторах, вызываемых в опера­торах 1 и 2). Вызов выполняется с помощью ключевого слова base. Вызывает­ся та версия конструктора, список параметров которой соответствует списку аргументов, указанных после слова base.

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

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

 

base.Passport ();

 

СОВЕТ ---------------------------------------------------------------------------------------------------------

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

-------------------------------------------------------------------------------------------------------------------

Вот, например, как выглядел бы метод Passport, если бы мы в классе Daemon хоте­ли не полностью переопределить поведение его предка, а дополнить его:

 

 

 

 

 

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

ВНИМАНИЕ -------------------------------------------------------------------------------------------------

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

---------------------------------------------------------------------------------------------------------------------

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

□   объект, в который во время выполнения программы заносятся ссылки на объ­екты разных классов иерархии;

□  контейнер, в котором хранятся объекты разных классов, относящиеся к од­ной иерархии;

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

□   метод, из которого в зависимости от типа вызвавшего его объекта вызывают­ся соответствующие методы.

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

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

 

 

 

 

 

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

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

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

 

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

 

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

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

 

virtual public void Passport() ...

 

Слово virtual в переводе с английского значит «фактический». Объявление ме­тода виртуальным означает, что все ссылки на этот метод будут разрешаться по факту его вызова, то есть не на стадии компиляции, а во время выполнения про­граммы. Этот механизм называется поздним связыванием.

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

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

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

 

override public void Passport () ...

 

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

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

 

 

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

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

Вызов виртуального метода выполняется так: из объекта берется адрес его таб­лицы VMT, из VMT выбирается адрес метода, а затем управление передается этому методу. Таким образом, при использовании виртуальных методов из всех одноименных методов иерархии всегда выбирается тот, который соответствует фактическому типу вызвавшего его объекта.

 ПРИМЕЧАНИЕ --------------------------------------------------------------------------------------------

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

-------------------------------------------------------------------------------------------------------------------

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

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

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

ПРИМЕЧАНИЕ ---------------------------------------------------------------------------------------------

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

 

Абстрактные классы

 

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

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

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

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------Абстрактный класс может содержать и полностью определенные методы, в отличие от сходного с ним по предназначению специального вида класса, называемого ин­терфейсом. Интерфейсы рассматриваются в следующей главе.

--------------------------------------------------------------------------------------------------------------------

Если в классе есть хотя бы один абстрактный метод, весь класс также должен быть описан как абстрактный, например:

 

 

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

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

 

ПРИМЕЧАНИЕ-------------------------------------------------------------------------------Мы уже использовали полиморфизм в разделе «Оператор foreach» (см. с. 136) для того, чтобы метод Print Array мог работать с массивом любого типа. Еще один при­мер применения абстрактных и виртуальных методов имеется в главе 10.

---------------------------------------------------------------------------------------------------------------------

Бесплодные классы

 

В С# есть ключевое слово sealed, позволяющее описать класс, от которого, в про­тивоположность абстрактному, наследовать запрещается:

 

 

 

 

 

 

 

 

 

 

 

 


Большинство встроенных типов данных описано как sealed. Если необходимо ис­пользовать функциональность бесплодного класса, применяется не наследование,; а вложение, или включение: в классе описывается поле соответствующего типа.

Вложение классов, когда один класс включает в себя поля, являющиеся класса­ми, является альтернативой наследованию при проектировании. Например, если есть объект «двигатель», а требуется описать объект «самолет», логично сделать двигатель полем этого объекта, а не его предком.

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

 

 

В методе Запустить двигатели запрос на запуск двигателей передается, или, как принято говорить, делегируется вложенному классу.

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

 

Класс object

 

Корневой класс System.Object всей иерархии объектов .NET, называемый в С# object, обеспечивает всех наследников несколькими важными методами. Про­изводные классы могут использовать эти методы непосредственно или пере­определять их.

Класс object часто используется и непосредственно при описании типа парамет­ров методов для придания им общности, а также для хранения ссылок на объек­ты различного типа — таким образом реализуется полиморфизм.

Открытые методы класса System /Object перечислены ниже.

 

□   Метод Equal s с одним параметром возвращает значение true, если параметр и вызывающий объект ссылаются на одну и ту же область памяти. Син­таксис:                                     

 

            public virtual bool Equals( object obj );

 

□   Метод Equals с двумя параметрами возвращает значение true, если оба пара­метра ссылаются на одну и ту же область памяти. Синтаксис:

 

            public static bool Equals ( object obi, object ob2 ):

 

□   Метод GetHashCode формирует хеш-код объекта и возвращает число, однознач­но идентифицирующее объект. Это число используется в различных структу­рах и алгоритмах библиотеки. Если переопределяется метод Equals, необходи­мо перегрузить и метод GetHashCode. Подробнее о хеш-кодах рассказывается в разделе «Абстрактные структуры данных» (см. с. 291). Синтаксис:

 

            public virtual int GetHashCode();

 

□   Метод Get Type возвращает текущий полиморфный тип объекта, то есть не тип ссылки, а тип объекта, на который она в данный момент указывает. Возвра­щаемое значение имеет тип Туре. Это абстрактный базовый класс иерархии, использующийся для получения информации о типах во время выполнения. Синтаксис:

 

            public Type GetType ();

 

□   Метод ReferenceEquals  возвращает значение true, если оба параметра ссыла­ются на одну и ту же область памяти. Синтаксис:

 

            public static bool ( object obi, object ob2 );

 

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

 

public virtual string ToString ()

 

В производных объектах эти методы часто переопределяют. Например, можно переопределить метод Equals для того, чтобы задать собственные критерии срав­нения объектов, потому что часто бывает удобнее использовать для сравнения не ссылочную семантику (равенство ссылок), а значимую (равенство значений).

Пример применения и переопределения методов класса object для класса Monster приведен в листинге 8.5.

 

 

 

 

 

 

 

В методе Equals сначала проверяется переданный в него аргумент. Если он равен null или его тип не соответствует типу объекта, вызвавшего метод, возвращается значение false. Значение true формируется в случае попарного равенства всех полей объектов.

Метод GetHashCode просто делегирует свои функции соответствующему методу одного из полей. Метод ToString формирует форматированную строку, содержа­щую значения полей объекта.

Анализируя результат работы программы, можно увидеть, что в операции срав­нения на равенство сравниваются ссылки, а в перегруженном методе Equals -значения. Для концептуального единства можно переопределить и операции от­ношения, соответствующий пример приведен в разделе «Перегрузка операций отношения» (см. с. 203).

 

Рекомендации по программированию

 

Наследование классов предоставляет программисту богатейшие возможности организации кода и его многократного использования. Выбор наиболее подходя­щих средств для целей конкретного проекта основывается на знании механизма их работы и взаимодействия.

Наследование класса Y от класса X означает, что Y представляет собой разновид­ность класса X, то есть более конкретную, частную концепцию. Базовый класс X является более общим понятием, чем Y. Везде, где можно использовать X, мож­но использовать и Y, но не наоборот (вспомните, что на место базового класса можно передавать любой из производных). Необходимо помнить, что во время выполнения программы не существует иерархии классов и передачи сообщений объектам базового класса из производных — есть только конкретные объекты классов, поля которых формируются на основе иерархии на этапе компиляции. Главное преимущество наследования состоит в том, что на уровне базового класса можно написать универсальный код, с помощью которого работать также с объ­ектами производного класса, что реализуется с помощью виртуальных методов. Как виртуальные должны быть описаны методы, которые выполняют во всех классах иерархии одну и ту же функцию, но, возможно, разными способами. Пусть, например, все объекты иерархии должны уметь выводить информацию о себе. Поскольку эта информация хранится в различных полях производных классов, функцию вывода нельзя реализовать в базовом классе. Естественно на­звать ее во всех классах одинаково и объявить как виртуальную с тем, чтобы ее можно было вызывать в зависимости от фактического типа объекта, с которым работают через базовый класс.

Для представления общих понятий, которые предполагается конкретизировать в производных классах, используют абстрактные классы. Как правило, в абст­рактном классе задается набор методов, то есть интерфейс, который каждый из потомков будет реализовывать по-своему.

 Обычные (не виртуальные) методы переопределять в производных классах не рекомендуется, поскольку производные классы должны наследовать свойства базовых, а спецификатор new, с помощью которого переопределяется обычный метод, «разрывает» отношение наследования на уровне метода. Иными словами, невиртуальный метод должен быть инвариантен относительно специализации, то есть должен сохранять свойства, унаследованные из базового класса незави­симо от того, как конкретизируется (специализируется) производный класс. Специализация производного класса достигается добавлением новых методов и переопределением существующих виртуальных методов. Альтернативным наследованию механизмом использования одним классом друго­го является вложение, когда один класс является полем другого. Вложение пред­ставляет отношения классов «Y содержит X» или «Y реализуется посредством X». Для выбора между наследованием и вложением служит ответ на вопрос о том, может ли у Y быть несколько объектов класса XY содержит X»). Кроме того, вложение используется вместо наследования тогда, когда про классы X и Y нельзя сказать, что Y является разновидностью X, но при этом Y использует часть функ­циональности XY реализуется посредством X»).

Глава 9

 

Интерфейсы и структурные типы

 

В этой главе рассматриваются специальные виды классов — интерфейсы, струк­туры и перечисления.

 

Синтаксис интерфейса

 

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

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

Синтаксис интерфейса аналогичен синтаксису класса:

           

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

 

Для интерфейса могут быть указаны спецификаторы new, public, protected, internal и private. Спецификатор new применяется для вложенных интерфейсов и имеет такой же смысл, как и соответствующий модификатор метода класса. Остальные спецификаторы управляют видимостью интерфейса. В разных контекстах опре­деления интерфейса допускаются разные спецификаторы. По умолчанию интер­фейс доступен только из сборки, в которой он описан (internal).

 

 

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

ПРИМЕЧАНИЕ ---------------------------------------------------------------------------------------------

Методом исключения можно догадаться, что интерфейс не может содержать кон­станты, поля, операции, конструкторы, деструкторы, типы и любые статические элементы.

-----------------------------------------------------------------------------------------------------------------

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

 

 

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

 

int Power { get; set; }                              

 

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

 

ВНИМАНИЕ--------------------------------------------------------------------------------------------------

Если некий набор действий имеет смысл только для какой-то конкретной иерархии классов, реализующих эти действия разными способами, уместнее задать этот набор в виде виртуальных методов абстрактного базового класса иерархии. То, что работа­ет в пределах иерархии одинаково, предпочтительно полностью определить в базо­вом классе (примерами таких действий являются свойства Health, Ammo и Name из иерархии персонажей игры). Интерфейсы же чаще используются для задания общих свойств объектов различных иерархий.

---------------------------------------------------------------------------------------------------------------------

Отличия интерфейса от абстрактного класса:

 

□   элементы интерфейса по умолчанию имеют спецификатор доступа public и не могут иметь спецификаторов, заданных явным образом;

□  интерфейс не может содержать полей и обычных методов — все элементы ин­терфейса должны быть абстрактными;

□ класс, в списке предков которого задается интерфейс, должен определять все его элементы, в то время как потомок абстрактного класса может не переоп­ределять часть абстрактных методов предка (в этом случае производный класс также будет абстрактным);

□ класс может иметь в списке предков несколько интерфейсов, при этом он должен определять все их методы.

 

 

ПРИМЕЧАНИЕ----------------------------------------------------------------------------------------------

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

-------------------------------------------------------------------------------------------------------------------

 

Реализация интерфейса

 

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

 

Например, реализация интерфейса I Action в классе Monster может выглядеть сле­дующим образом:

 

 

 

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

 

 

 

 

 

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

 

 

Удобство второго способа проявляется при присваивании объектам типа I Action ссылок на объекты различных классов, поддерживающих этот интерфейс. На­пример, легко себе представить метод с параметром типа интерфейса. На место этого параметра можно передавать любой объект, реализующий интерфейс:

 

 

 

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

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

 

 

 

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

 

 

Впрочем, если от таких методов не требуется разное поведение, можно реали­зовать метод первым способом (со спецификатором public), компилятор не воз­ражает:

 

 

К методу Draw, описанному таким образом, можно обращаться любым способом: через объект класса Monster, через интерфейс IAction или ITest. Конфликт возникает в том случае, если компилятор не может определить из контекста обращения к элементу, элемент какого именно из реализуемых ин­терфейсов требуется вызвать. При этом всегда помогает явное задание имени интерфейса.

 

Работа с объектами через интерфейсы. Операции is и as

 

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

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

 

 

 

Допустим, мы оформили какие-то действия с объектами в виде метода с пара­метром типа object. Прежде чем использовать этот параметр внутри метода для обращения к методам, описанным в производных классах, требуется выполнить преобразование к производному классу. Для безопасного преобразования следу­ет проверить, возможно ли оно, например так:

 

 

 

В метод Act можно передавать любые объекты, но на экран будут выведены толь­ко те, которые поддерживают интерфейс IAction.

Недостатком использования операции is является то, что преобразование фак­тически выполняется дважды: при проверке и при собственно преобразовании. Более эффективной является другая операция — as. Она выполняет преобра­зование к заданному типу, а если это невозможно, формирует результат null, например:

 

Обе рассмотренные операции применяются как к интерфейсам, так и к классам.

 

Интерфейсы и наследование

 

Интерфейс может не иметь или иметь сколько угодно интерфейсов-предков, в по­следнем случае он наследует все элементы всех своих базовых интерфейсов, начиная с самого верхнего уровня. Базовые интерфейсы должны быть доступны в не меньшей степени, чем их потомки. Например, нельзя использовать интер­фейс, Лшсанный со спецификатором private или internal, в качестве базового для открытого (public) интерфейса.

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

 

Метод F из интерфейса IBase скрыт интерфейсом ILeft, несмотря на то что в це­почке IDerived IRightIBase он не переопределялся.

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

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

 

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

 

 

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

 

 

Однако если интерфейс реализуется с помощью виртуального метода класса, по­сле его переопределения в потомке любой вариант обращения (через класс или через интерфейс) приведет к одному и тому же результату:

 

 

Метод интерфейса, реализованный явным указанием имени, объявлять виртуаль­ным запрещается. При необходимости переопределить в потомках его поведение пользуются следующим приемом: из этого метода вызывается другой, защищен­ный метод, который объявляется виртуальным. В приведенном далее примере метод А интерфейса IBase реализуется посредством защищенного виртуального метода А_, который можно переопределять в потомках класса Base:

 

 

Существует возможность повторно реализовать интерфейс, указав его имя в спи­ске предков класса наряду с классом-предком, уже реализовавшим этот интер­фейс. При этом реализация переопределенных методов базового класса во вни­мание не принимается:

 

 

 

 

Если класс наследует от класса и интерфейса, которые содержат методы с одина­ковыми сигнатурами, унаследованный метод класса воспринимается как реали­зация интерфейса, например:

 

Здесь класс Class2 наследует от класса Classl метод F. Интерфейс Interfacel так­же содержит метод F. Компилятор не выдает ошибку, потому что класс Class2 со­держит метод, подходящий для реализации интерфейса.

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

 

Стандартные интерфейсы .NET

 

В библиотеке классов .NET определено множество стандартных интерфейсов, задающих желаемое поведение объектов. Например, интерфейс I Comparable зада­ет метод сравнения объектов по принципу больше или меньше, что позволяет выполнять их сортировку. Реализация интерфейсов I Enumerable и I Enumerator дает возможность просматривать содержимое объекта с помощью конструкции foreach, а реализация интерфейса ICIoneable — клонировать объекты.

Стандартные интерфейсы поддерживаются многими стандартными классами биб­лиотеки. Например, работа с массивами с помощью цикла foreach возможна имен­но потому, что тип Array реализует интерфейсы I Enumerable и I Enumerator. Можно создавать и собственные классы, поддерживающие стандартные интерфейсы, что позволит использовать объекты этих классов стандартными способами.

 

Сравнение объектов (интерфейс IComparable)

 

Интерфейс IComparable определен в пространстве имен System. Он содержит всего один метод СоmраrеТо, возвращающий результат сравнения двух объектов — теку­щего и переданного ему в качестве параметра:

 

interface IComparable

{

            int CompareTo( object obj )                                .

}                                            \

 

Метод должен возвращать:

 

□  0, если текущий объект и параметр равны;

□  отрицательное число, если текущий объект меньше параметра;

□  положительное число, если текущий объект больше параметра.

Реализуем интерфейс IComparable в знакомом нам классе Monster. В качестве кри­терия сравнения объектов выберем поле health. В листинге 9.1 приведена про­грамма, сортирующая массив монстров по возрастанию величины, характери­зующей их здоровье (элементы класса, не используемые в данной программе, не приводятся).

 

 

 

 

 

 

 

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

Во многих алгоритмах требуется выполнять сортировку объектов по различным критериям. В С# для этого используется интерфейс I Comparer, который рассмот­рен в следующем разделе.

 

Сортировка по разным критериям (интерфейс IComparer)

 

Интерфейс IComparer определен в пространстве имен System.Col lections. Он со­держит один метод СоmраrеТо, возвращающий результат сравнения двух объек­тов, переданных ему в качестве параметров:

 

interface IComparer

{

     int Compare ( object obi, object ob2 )

 

}

 

 

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

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

 

 

 

 

 

 

 


                                                                                     

 

 

Перегрузка операций отношения

 

Если класс реализует интерфейс I Comparable, его экземпляры можно сравнивать между собой по принципу больше или меньше. Логично разрешить использовать для этого операции отношения, перегрузив их. Операции должны перегружаться парами:

 < и >, <= и >=, = и !=. Перегрузка операций обычно выполняется путем де­легирования, то есть обращения к переопределенным методам СоmраrеТо и Equals.

 

ПРИМЕЧАНИЕ ---------------------------------------------------------------------------------------------

Если (класс реализует интерфейс IComparable, требуется переопределить метод Equals и связанный с ним метод GetHashCode. Оба метода унаследованы от базового класса object. Пример перегрузки был приведен в разделе «Класс object» (см. с. 183).

---------------------------------------------------------------------------------------------------------------------

В листинге 9.3 операции отношения перегружены для класса Monster. В качестве критерия сравнения объектов по принципу больше или меньше выступает поле health, а при сравнении на равенство реализуется значимая семантика, то есть попарно сравниваются все поля объектов

 

 

 

 

Клонирование объектов (интерфейс ICIoneable)

 

Клонирование — это создание копии объекта. Копия объекта называется кло­ном. Как вам известно, при присваивании одного объекта ссылочного типа дру­гому копируется ссылка, а не сам объект (рис. 9.1, а). Если необходимо скопи­ровать в другую область памяти поля объекта, можно воспользоваться методом MemberwiseCI one, который любой объект наследует от класса object. При этом объ­екты, на которые указывают поля объекта, в свою очередь являющиеся ссылка­ми, не копируются (рис. 9.1,  б). Это называется поверхностным клонированием.

 

 

Для создания полностью независимых объектов необходимо глубокое клонирова­ние, когда в памяти создается дубликат всего дерева объектов, то есть объектов, на которые ссылаются поля объекта, поля полей и т. д. (рис. 9.1, в). Алгоритм глубокого клонирования весьма сложен, поскольку требует рекурсивного обхода всех ссылок объекта и отслеживания циклических зависимостей.

Объект, имеющий собственные алгоритмы клонирования, должен объявляться как наследник интерфейса ICloneabie и переопределять его единственный метод Clone. В листинге 9.4 приведен пример создания поверхностной копии объекта класса Monster с помощью метода MemberwiseCIone, а также реализован интерфейс ICIoneable. В демонстрационных целях в имя клона объекта добавлено слово «Клон». Обратите внимание на то, что метод MemberwiseCIone можно вызвать только из методов класса. Он не может быть вызван непосредственно, поскольку объявлен в классе object как защищенный (protected).

 

 

 

Объект X ссылается на ту же область памяти, что и объект Вася. Следовательно, если мы внесем изменения в один из этих объектов, это отразится на другом. Объекты Y и Z, созданные путем клонирования, обладают собственными копия­ми значений полей и независимы от исходного объекта.

 

 

Перебор объектов (интерфейс I Enumerable) и итераторы

 

Оператор foreach является удобным средством перебора элементов объекта. Массивы и все стандартные коллекции библиотеки .NET позволяют выполнять такой перебор благодаря тому, что в них реализованы интерфейсы I Enumerable и I Enumerator. Для применения оператора foreach к пользовательскому типу дан­ных требуется реализовать в нем эти интерфейсы. Давайте посмотрим, как это делается.

Интерфейс I Enumerable (перечислимый) определяет всего один метод — GetEnumerator, возвращающий объект типа I Enumerator (перечислитель), который можно исполь­зовать для просмотра элементов объекта.

Интерфейс I Enumerator задает три элемента:

□   свойство Current, возвращающее текущий элемент объекта;

□   метод MoveNext, продвигающий перечислитель на следующий элемент объекта;

□   метод Reset, устанавливающий перечислитель в начало просмотра.

Цикл foreach использует эти методы для перебора элементов, из которых состо­ит объект.

Таким образом, если требуется, чтобы для перебора элементов класса мог приме­няться цикл foreach, необходимо реализовать четыре метода: GetEnumerator, Current, MoveNext и Reset. Например, если внутренние элементы класса организованы в мас­сив, потребуется описать закрытое поле класса, хранящее текущий индекс в мас­сиве, в методе MoveNext задать изменение этого индекса на 1 с проверкой выхода за границу массива, в методе Current — возврат элемента массива по текущему индексу и т. д.

Это не интересная работа, а выполнять ее приходится часто, поэтому в версию 2.0 были введены средства, облегчающие выполнение перебора в объекте -- итераторы.

Итератор представляет собой блок кода, задающий последовательность перебо­ра элементов объекта. На каждом проходе цикла foreach выполняется один шаг

итератора, заканчивающийся выдачей очередного значения. Выдача значения выполняется с помощью ключевого слова yield.

Рассмотрим создание итератора на примере (листинг 9.5). Пусть требуется соз­дать объект, содержащий боевую группу экземпляров типа Monster, неоднократно использованного в примерах этой книги. Для простоты ограничим максималь­ное количество бойцов в группе десятью.

 

 

 

Все, что требуется сделать в версии 2.0 для поддержки перебора, — указать, что класс реализует интерфейс IEnumerable (оператор 1), и описать итератор (опера­тор 2). Доступ к нему может быть осуществлен через методы MoveNext и Current интерфейса I Enumerator.                                                                    

За кодом, приведенным в листинге 9.5, стоит большая внутренняя работа компи­лятора. На каждом шаге цикла foreach для итератора создается «оболочка» — служебный объект, который запоминает текущее состояние итератора и выпол­няет все необходимое для доступа к просматриваемым элементам объекта. Ины­ми словами, код, составляющий итератор, не выполняется так, как он выглядит — в виде непрерывной последовательности, а разбит на отдельные итерации, между которыми состояние итератора сохраняется. В листинге 9.6 приведен пример итератора, перебирающего четыре заданных строки.

 

 

 

 

 

 

Преимущество использования итераторов заключается в том, что для одного и того же класса можно задать различный порядок перебора элементов. В листинге 9.7 описаны две дополнительные стратегии перебора элементов класса Stado, введен­ного в листинге 9.5, — перебор в обратном порядке и выборка только тех объек­тов, которые являются экземплярами класса Monster (для этого использован ме­тод получения типа объекта Get Type, унаследованный от базового класса object).

 

 

Теперь, когда вы получили представление об итераторах, рассмотрим их более формально.

Блок итератора синтаксически представляет собой обычный блок и может встре­чаться в теле метода, операции или части get свойства, если соответствующее возвращаемое значение имеет тип IEnumerable или IEnumerator.

В теле блока итератора могут встречаться две конструкции:

□  yield return формирует значение, выдаваемое на очередной итерации;

□  yield break сигнализирует о завершении итерации.

Ключевое слово yield имеет специальное значение для компилятора только в этих конструкциях.

Код блока итератора выполняется не так, как обычные блоки. Компилятор фор­мирует служебный объект-перечислитель, при вызове метода MoveNext которого выполняется код блока итератора, выдающий очередное значение с помощью ключевого слова yield. Следующий вызов метода MoveNext объекта-перечислите­ля возобновляет выполнение блока итератора с момента, на котором он был при­остановлен в предыдущий раз.

 

Структуры

 

Структура — тип данных, аналогичный классу, но имеющий ряд важных отли­чий от него:

□   структура является значимым, а не ссылочным типом данных, то есть экземп­ляр структуры хранит значения своих элементов, а не ссылки на них, и распо­лагается в стеке, а не в хипе;       

□   структура не может участвовать в иерархиях наследования, она может только реализовывать интерфейсы;

□   в структуре запрещено определять конструктор по умолчанию, поскольку он определен неявно и присваивает всем ее элементам значения по умолчанию (нули соответствующего типа);

□   в структуре запрещено определять деструкторы, поскольку это бессмысленно.

 

ПРИМЕЧАНИЕ --------------------------------------------------------------------------------------

Строго говоря, любой значимый тип С# является структурным.

-------------------------------------------------------------------------------------------------------------

Отличия от классов обусловливают область применения структур: типы данных, имеющие небольшое количество полей, с которыми удобнее работать как со зна­чениями, а не как со ссылками. Накладные расходы на динамическое выделение памяти для небольших объектов могут весьма значительно снизить быстродействие программы, поэтому их эффективнее описывать как структуры, а не как классы.

 

ПРИМЕЧАНИЕ-----------------------------------------------------------------------------------------

С другой стороны, передача структуры в метод по значению требует и дополни­тельного времени, и дополнительной памяти.

---------------------------------------------------------------------------------------------------------------

Синтаксис структуры:

 

[ атрибуты ] [ спецификаторы ] struct имя_структуры [ : интерфейсы ]              тело_структуры [ ; ]

 

Спецификаторы структуры имеют такой же смысл, как и для класса, причем из спецификаторов доступа допускаются только public, internal и private (послед­ний — только для вложенных структур).

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

□   поскольку структуры не могут участвовать в иерархиях, для их элементов не могут использоваться спецификаторы protected и protected internal;

□   структуры не могут быть абстрактными (abstract), к тому же по умолчанию они бесплодны (sealed);

□  методы структур не могут быть абстрактными и виртуальными;

□  переопределяться (то есть описываться со спецификатором override) могут только методы, унаследованные от базового класса object;

□  параметр this интерпретируется как значение, поэтому его можно использо­вать для ссылок, но не для присваивания;

□  при описании структуры нельзя задавать значения полей по умолчанию— это будет сделано в конструкторе по умолчанию, создаваемом автоматически (конструктор присваивает значимым полям структуры нули, а ссылочным — значение null).

В листинге 9.8 приведен пример описания структуры, представляющей комплекс­ное число. Для экономии места из всех операций приведено только описание сложения. Обратите внимание на перегруженный метод ToString: он позволяет выводить экземпляры структуры на консоль, поскольку неявно вызывается в ме­тоде Console.WriteLine. Использованные в методе спецификаторы формата опи­саны в приложении.

 

 

 

 

При выводе экземпляра структуры на консоль выполняется упаковка, то есть не­явное преобразование в ссылочный тип. Упаковка (это понятие было введено в разделе «Упаковка и распаковка», см. с. 36) применяется и в других случаях, когда структурный тип используется там, где ожидается ссылочный, например, при преобразовании экземпляра структуры к типу реализуемого ею интерфейса. При обратном преобразовании — из ссылочного типа в структурный — выполня­ется распаковка.

Присваивание структур имеет, что естественно, значимую семантику, то есть при присваивании создается копия значений полей. То же самое происходит и при передаче структур в качестве параметров по значению. Для экономии ре­сурсов ничто не мешает передавать структуры в методы по ссылке с помощью ключевых слов ref или out.

Особенно значительный выигрыш в эффективности можно получить, используя массивы структур вместо массивов классов. Например, для массива из 100 эк­земпляров класса создается 101 объект, а для массива структур — один объ­ект. Пример работы с массивом структур, описанных в предыдущем листинге:

 

Если поместить этот фрагмент вместо тела метода Main в листинге 9.5, получим следующий результат:

 

 

Перечисления

 

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

 

enum Menu { Read, Write, Append, Exit }

enum Радуга { Красный, Оранжевый, Желтый, Зеленый, Синий, Фиолетовый }

 

Для каждой константы задается ее символическое имя. По умолчанию констан­там присваиваются последовательные значения типа int, начиная с 0, но можно задать и собственные значения, например:

 

enum Nums { two = 2, three, four, ten = 10, eleven, fifty = ten + 40 };

 

Константам three и four присваиваются значения З и 4, константе eleven —11. Имена перечисляемых констант внутри каждого перечисления должны быть уникальными, а значения могут совпадать.

Преимущество перечисления перед описанием именованных констант состоит в том, что связанные константы нагляднее; кроме того, компилятор выполняет проверку типов, а интегрированная среда разработки подсказывает возможные значения констант, выводя их список.

 

Синтаксис перечисления:

 

[ атрибуты ] [ спецификаторы ] enum имя_перечисления [ : базовый_тип ]       тело_перечисления [ ; ]

 

Спецификаторы перечисления имеют такой же смысл, как и для класса, причем допускаются только спецификаторы new, public, protected, internal и private.

Базовый тип — это тип элементов, из которых построено перечисление. По умол­чанию используется тип int, но можно задать тип и явным образом, выбрав его среди целочисленных типов (кроме char), а именно: byte, sbyte, short, ushort, int, utnt, long и ulong. Необходимость в этом возникает, когда значения констант не­возможно или неудобно представлять с помощью типа int. Тело перечисления состоит из имен констант, каждой из которых может быть присвоено значение. Если значение не указано, оно вычисляется прибавлением единицы к значению предыдущей константы. Константы по умолчанию имеют спецификатор доступа publiс.

Перечисления часто используются как вложенные типы, идентифицируя значе­ния из какого-либо ограниченного набора. Пример такого перечисления приве­ден в листинге 9.9.

Операции с перечислениями

 

С переменными перечисляемого типа можно выполнять арифметические операции

 (+, -, ++, --), логические поразрядные операции (*, &, |, ~), сравнивать их с помо­щью операций отношения (<, <=, >, >=, ==, !=) и ползать размер в байтах (sizeof).

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

базового типа, то есть не только одно из значений, входящих в тело перечисления. Присваиваемое значение становится новым элементом перечисления.

Пример:

 

 

Результат работы этого фрагмента программы ({0,2:Х} обозначает шестнадцатеричный формат вывода):

 

 

Другой пример использования операций с перечислениями приведен в листинге 9.10.

 

 

 

Базовый класс System. En urn

 

Все перечисления в С# являются потомками базового класса System.Enum, кото­рый снабжает их некоторыми полезными методами.

Статический метод GetName позволяет получить символическое имя константы по ее номеру, например:

 

ПРИМЕЧАНИЕ------------------------—-------------------------—-----------------------------------

Операция typeof возвращает тип своего аргумента (см. раздел «Рефлексия» в главе 12).

-------------------------------------------------------------------------------------------------------------------

Статические методы GetNames и GetVaiues формируют, соответственно, массивы i имен и значений констант, составляющих перечисление, например:

 

 

Статический метод IsDefined возвращает значение true, если константа с задан­ным символическим именем описана в указанном перечислении, и false в про­тивном случае, например:

 

Статический метод GetUnderlyingType возвращает имя базового типа, на котором по­строено перечисление. Например, для перечисления Flags будет получено System.Byte:

 

 

Рекомендации по программированию

 

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

Если некий набор действий имеет смысл только для какой-то конкретной иерар­хии классов, реализующих эти действия разными способами, уместнее задать этот набор в виде виртуальных методов абстрактного базового класса иерархии.

В С# поддерживается одиночное наследование для классов и множественное — для интерфейсов. Это позволяет придать производному классу свойства несколь­ких базовых интерфейсов. Класс должен определять все методы всех интерфей­сов, которые имеются в списке его предков.

В библиотеке .NET определено большое количество стандартных интерфейсов. Реализация стандартных интерфейсов в собственных классах позволяет исполь­зовать для объектов этих классов стандартные средства языка и библиотеки.

Например, для обеспечения возможности сортировки объектов стандартными ме­тодами следует реализовать в соответствующем классе интерфейсы I Comparable или IComparer. Реализация интерфейсов IEnumerabie и IEnumerator дает возможность просматривать содержимое объекта с помощью конструкции foreach, а реализа­ция интерфейса ICloneable — клонировать объекты.

Использование итераторов упрощает организацию перебора элементов и позво­ляет задать для одного и того же класса различные стратегии перебора.

Область применения структур — типы данных, имеющие небольшое количест­во полей, с которыми удобнее работать как со значениями, а не как со ссылками. Накладные расходы на динамическое выделение памяти для экземпляров не­больших классов могут весьма значительно снизить быстродействие программы, поэтому их эффективнее описывать как структуры.

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