Классы: подробности
В этой главе мы продолжим знакомство с элементами классов, начатое в главе 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, а три перегруженных варианта с обычными параметрами.
Иерархии классов
Управлять большим количеством разрозненных классов довольно сложно. С этой проблемой можно справиться путем упорядочивания и ранжирования классов, то есть объединяя общие для нескольких классов свойства в одном классе и используя его в качестве базового.
Эту возможность предоставляет механизм наследования, который является мощнейшим инструментом ООП. Он позволяет строить иерархии, в которых классы-потомки получают свойства классов-предков и могут дополнять их или изменять. Таким образом, наследование обеспечивает важную возможность многократного использования кода. Написав и отладив код базового класса, можно, не изменяя его, за счет наследования приспособить класс для работы в различных ситуациях. Это экономит время разработки и повышает надежность программ.
Классы, расположенные ближе к началу иерархии, объединяют в себе общие черты для всех нижележащих классов. По мере продвижения вниз по иерархии классы приобретают все больше конкретных особенностей.
Итак, наследование применяется для следующих взаимосвязанных целей:
□ исключения из программы повторяющихся фрагментов кода;
□ упрощения модификации программы;
□ упрощения создания новых программ на основе существующих.
Кроме того, наследование является единственной возможностью использовать объекты, исходный код которых недоступен, но в которые требуется внести изменения.
Класс в С# может иметь произвольное количество потомков и только одного предка. При описании класса имя его предка записывается в заголовке класса
после двоеточия. Если имя предка не указано, предком считается базовый класс всей иерархии 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 быть несколько объектов класса X («Y содержит X»). Кроме того, вложение используется вместо наследования тогда, когда про классы X и Y нельзя сказать, что Y является разновидностью X, но при этом Y использует часть функциональности X («Y реализуется посредством X»).
Интерфейсы и структурные типы
В этой главе рассматриваются специальные виды классов — интерфейсы, структуры и перечисления.
Интерфейс является «крайним случаем» абстрактного класса. В нем задается набор абстрактных методов, свойств и индексаторов, которые должны быть реализованы в производных классах. Иными словами, интерфейс определяет поведение, которое поддерживается реализующими этот интерфейс классами. Основная идея использования интерфейса состоит в том, чтобы к объектам таких классов можно было обращаться одинаковым образом.
Каждый класс может определять элементы интерфейса по-своему. Так достигается полиморфизм: объекты разных классов по-разному реагируют на вызовы одного и того же метода
Синтаксис интерфейса аналогичен синтаксису класса:
[ атрибуты ] [ спецификаторы ] 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 — IRight — IBase он не переопределялся.
Класс, реализующий интерфейс, должен определять все его элементы, в том числе унаследованные. Если при этом явно указывается имя интерфейса, оно
должно ссылаться на тот интерфейс, в котором был описан соответствующий элемент, например:
Интерфейс, на собственные или унаследованные элементы которого имеется явная ссылка, должен быть указан в списке предков класса, например:
Класс наследует все методы своего предка, в том числе те, которые реализовывали интерфейсы. Он может переопределить эти методы с помощью спецификатора new, но обращаться к ним можно будет только через объект класса. Если использовать для обращения ссылку на интерфейс, вызывается не переопределенная версия:
Однако если интерфейс реализуется с помощью виртуального метода класса, после его переопределения в потомке любой вариант обращения (через класс или через интерфейс) приведет к одному и тому же результату:
Метод интерфейса, реализованный явным указанием имени, объявлять виртуальным запрещается. При необходимости переопределить в потомках его поведение пользуются следующим приемом: из этого метода вызывается другой, защищенный метод, который объявляется виртуальным. В приведенном далее примере метод А интерфейса IBase реализуется посредством защищенного виртуального метода А_, который можно переопределять в потомках класса Base:
Существует возможность повторно реализовать интерфейс, указав его имя в списке предков класса наряду с классом-предком, уже реализовавшим этот интерфейс. При этом реализация переопределенных методов базового класса во внимание не принимается:
Если класс наследует от класса и интерфейса, которые содержат методы с одинаковыми сигнатурами, унаследованный метод класса воспринимается как реализация интерфейса, например:
Здесь класс Class2 наследует от класса Classl метод F. Интерфейс Interfacel также содержит метод F. Компилятор не выдает ошибку, потому что класс Class2 содержит метод, подходящий для реализации интерфейса.
Вообще при реализации интерфейса учитывается наличие «подходящих» методов в классе независимо от их происхождения. Это могут быть методы, описанные в текущем или базовом классе, реализующие интерфейс явным или неявным образом.
В библиотеке классов .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 — клонировать объекты.
Использование итераторов упрощает организацию перебора элементов и позволяет задать для одного и того же класса различные стратегии перебора.
Область применения структур — типы данных, имеющие небольшое количество полей, с которыми удобнее работать как со значениями, а не как со ссылками. Накладные расходы на динамическое выделение памяти для экземпляров небольших классов могут весьма значительно снизить быстродействие программы, поэтому их эффективнее описывать как структуры.
Преимущество использования перечислений для описания связанных между собой значений состоит в том, что это более наглядно и инкапсулировано, чем россыпь именованных констант. Кроме того, компилятор выполняет проверку типов, а интегрированная среда разработки подсказывает возможные значения констант, выводя их список.