Опубликован: 06.10.2011 | Уровень: для всех | Доступ: платный
Дополнительный материал 2:

Введение в C# (по материалам Бенджамина Моранди)

Интерфейсы

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

interface IOrdered <T> {
   bool lesser (T other);
   bool greater (T other);
   bool lesserEqual (T other);
   bool greaterEqual (T other); 
}

По соглашению имена интерфейсов должны начинаться с буквы I. Пример показывает, что интерфейс может быть универсальным. Важно, что интерфейсы объявляются без указания модификатора доступа, - они неявно имеют статус public.

Данный интерфейс задает свойство сравнимости объектов, используя общепринятые имена операций сравнения. Он также иллюстрирует границы понятия интерфейса - поскольку методы не могут иметь реализации, нельзя задать, что a.greater (b) должно быть реализовано как b.lesser (a) (для абстрактного класса такое возможно, но только один родитель может быть классом, не имеет значения - абстрактным или эффективным).

Класс может наследовать от одного или нескольких интерфейсов, обеспечивая реализации методов:

class TennisPlayer: IOrdered<TennisPlayer>, IAnotherInterface {
   public int ranking;
   public bool lesser (TennisPlayer other) {return (ranking < other.ranking);}
   … Аналогично для greater, lesserEqual, greaterEqual …
   … Реализация методов IAnotherInterfacei …
   … Другие компоненты TennisPlayer … 
}

Множественное наследование интерфейсов не исключает конфликта имен. В C# нет явного механизма переименования, аналогичного Eiffel, но конфликта можно избежать, используя в качестве префикса имя интерфейса и нотацию с точкой, например, IAnotherInterface.clashingname.

Доступность и наследование

В связи с наследованием в дополнение к трем ранее рассмотренным модификаторам доступа (public, internal, private) добавляется новый модификатор и новая комбинация:

  • protected: доступно для потомков;
  • protected internal: доступно потомкам и классам той же сборки.

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

Переопределение и динамическое связывание

Наследуемый метод можно переопределить. Соглашения, однако, отличаются от других современных языков программирования. Как в С++, и в отличие от Eiffel и Java, связывание в C# - удивительно для языка, первая версия которого появилась в 1999 году, - статическое. Другими словами, версия f, выполняемая при вызове a.f, будет следовать объявлению a, но не динамическому типу объекта, связанному с а в момент выполнения. Чтобы выполнялось динамическое связывание, метод должен быть объявлен как виртуальный - virtual:

class P {
   public virtual void f (…) {…}
   … 
}

Потомок переопределяет виртуальный метод, в точности сохраняя сигнатуру и заменяя модификатор virtual на override:

class Q: P {
   public override void f (…) {…}
   … 
}

Оригинальная версия переопределяемого метода остается доступной, как с Precursor в Eiffel, для этого применяется нотация с точкой, а в качестве префикса используется имя родителя - base. Например, реализация f в Q может пользоваться результатами работы, проделанной родителем:

base.f (n);

Для реализации динамического связывания требуется точное соответствие схеме (оригинал метода специфицирован как virtual, новый - как override1). Если условие не выполняется, то компилятор по-другому интерпретирует текст. Рассмотрим следующий вариант:

class R {public void f (int i) {…}} 
class S: R {public void f (int i) {…}} 
R r1 ; S s1 = new S() ; int n; 
r1 = s1;

Оригинал f не объявлен virtual в R, но все же потомок S дал новую реализацию. Это не является переопределением метода родителя. Это интерпретируется как создание потомком нового метода с тем же именем, скрывающего метод родителя (доступный через base). В этой ситуации применяется статическое связывание. Вызов s1.f(n) будет использовать новую версию, но вызов r1.f(n) будет использовать версию R независимо от того, с каким объектом связано будет r1 во время выполнения. Это довольно опасно, хотя компилятор отслеживает ситуацию и выдает предупреждающие сообщения. Для защиты от риска ошибки новый метод следует помечать как new:

class S: R {public new void f (int i) {…}

Динамическое связывание не встретится и в случае вызова p1.f (n) , где p1 типа P, и динамически типа Q, если в Q опустить при переопределении модификатор override. Это не будет восприниматься как ошибка, просто означает применение статического связывания.

При переопределении можно задать модификатор sealed, чтобы защитить метод от переопределения у потомков:

class Q1: P {
   public sealed override void f (…) {…}
   … 
}

Этот модификатор допускается только для override-методов, поскольку остальные по определению не являются переопределяемыми. Модификатор sealed может быть у класса (sealed class T …), и тогда такой класс не может иметь потомков.

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

Выбор в языке C# по умолчанию статического связывания ошибочен. Из этого наблюдения следует методологическое правило для C#-программистов: всегда объявлять методы virtual, удаляя этот модификатор в тех редких случаях, когда метод должен быть защищён от переопределения - sealed.

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

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

  • Конструктор может вызвать конструктор родителя, используя нотацию base (как base (n); ). Так как конструкторы не имеют индивидуального имени, сигнатура аргументов, благодаря правилам перегрузки, однозначно определяет, какой конструктор будет вызван.
  • В отсутствие такого вызова родитель должен иметь конструктор без аргументов, который будет автоматически выполняться перед тем, как начнет свое выполнение конструктор потомка.

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

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

Идентификация типа в период выполнения

Для приведения к типу U выражения exp типа T (как в тесте объектов Eiffel) можно использовать два механизма.

  • Явный кастинг - написать (U) exp, не учитывая объявленный тип exp. Если все хорошо и exp в самом деле представляет объект типа U в момент выполнения, то можно использовать это выражение для ссылки на значение приведенного типа. Обратная сторона в том, что если динамический тип не соответствует U, попытка вычисления выражения приведет к возникновению исключительной ситуации, обработку которой надо предусмотреть для безопасного программирования.
  • Более приемлемым способом является использование специальной C#-конструкции - булевского выражения: exp is U. Во втором случае exp по-прежнему статически принадлежит объявленному типу T, но теперь можно комбинировать два механизма с гарантией, что кастинг будет работать:

    if (exp is U) {r ((U) exp)};   // Метод r объявлен как: r(U : x) {…}
    

Другие механизмы структурирования программ

В C# введены несколько механизмов структурирования программ, не входящих в базисную ОО-парадигму.

Пространства имен

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

По умолчанию имена всех типов (классов) принадлежат глобальному пространству имён. Разрешается определять собственное пространство имен, содержащее объявления классов:

namespace N1 {
… Объявления классов (и других типов по желанию) …
}

В этом случае клиент, которому необходим доступ к нескольким классам с одним именем, скажем, С, может устранить неопределенность, используя нотацию N1.C.

Важным предопределенным пространством имен является пространство System, содержащее базисные библиотеки классов.

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

namespace N1.N2 {// N2 - подпространство N1: элементы доступны в нотации
                    // N1.N2.V. 
… Объявления классов, включая V … 
}

Клиент, часто использующий классы из некоторого пространства имен, может избежать повторения квалифицированных имен (как в N1.N2.C, N1.N2.D и т. д.) благодаря использованию директивы using:

using N1.N2;
using SomeOtherNamespace;
… Здесь можно использовать V как сокращенную запись N1.N2.V …

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

Методы расширения

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

Чтобы справиться с этой проблемой, C# обеспечивает интересный механизм расширения методов: методы добавляются извне к существующему классу.

Очевидно, методы расширения представляют просто синтаксическое упрощение, так как проблему можно решить за счет статических методов: в любой класс можно добавить статический метод sm, который будет вызываться как sm (x1, other_args) с x1 типа X. Однако мы хотим, чтобы метод m вызывался в том же стиле, как если бы он был методом X:

x1.m (other_args);	
9.

При этом m объявляется не в классе X, а в другом классе. Синтаксический трюк состоит в том, чтобы маркировать первый аргумент m модификатором this:

public static class Y {
     static void m (this X x, int arg1, int arg2) {…}
     … 
}

В результате в классе X появляется расширенный метод и [9] становится правильной конструкцией (с двумя целочисленными аргументами, заменяющими other_args).

Атрибуты

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

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

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

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

[Serializable]
public class Z {… Обычное объявление класса …}

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

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

public class ChangeAttribute: System.Attribute {
   private string author;
   private string last;
   public ChangeAttribute (string a, string l) 
      {author = a ; last = l;}
   public string revision; 
}

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

Тогда можно поставлять класс или (здесь) метод с информацией о версии:

[ChangeAttribute ("Caroline", "24 December 2009")] public void r {…}

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

[ChangeAttribute ("Caroline", "24 December 2009", revision = "2.1" )] 
public void r {…}

Атрибут ChangeAttribute, в том виде как он объявлен, применим к любым программным элементам: class, struct, method, field, delegate и некоторым другим. Можно ограничить применимость атрибута, присоединив собственное объявление у атрибута AttributeUsage:

[System.AttributeUsage(System.AttributeTargets.Class)]
public class ChangeAttribute: System.Attribute {… Остальное, как прежде …}

Вместо задания Class (в качестве области действия атрибута) можно использовать другие ключевые слова: All (по умолчанию), Assembly, Delegate, Event, Interface, Field, Method, Parameter, Struct. Разрешается задавать несколько целей, разделяя их символом вертикальной черты |.

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

o.GetType().GetCustomAttributes(true);

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

Отсутствующие элементы

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

Контракты введены в исследовательскую версию языка C# - SpecC#, разработанную в Microsoft Research. Эта версия языка доступна для свободного использования.

Специфические свойства языка

Полезно ознакомиться с несколькими поддерживающими C# конструкциями.

Небезопасный код

В C# комбинируются строгие требования к безопасности типов с предоставлением программистам возможности работы на низком уровне, характерном для языка С или ассемблера. Конструкция "unsafe" поддерживает четкое разделение между небезопасными элементами и нормальными, прошедшими проверку типов.

Объявление метода небезопасным (unsafe) означает, что область данных расположена вне "кучи" - области, отводимой под объекты. Небезопасный метод может осуществлять в своей области непосредственные манипуляции с указателями и, следовательно, игнорировать нормальные правила работы с данными.

Тип "перечисление"

Типы, заданные перечислением, позволяют оперировать значениями из конечного множества предопределенных значений:

enum CardColors {Spades, Hearts, Diamonds, Clubs}

Значения, заданные перечислением, проецируются на целочисленный тип, по умолчанию int, но можно задать и другой тип, как в enum T:long {…}. Значения проецируются на отрезок, начинающийся с 0, но можно задать и другое начало:

enum CardColors1 {Spades = 1, Hearts, Diamonds, Clubs}

Значения можно обозначать, используя нотацию с точкой, как в CardColors.Spades. Допустимы взаимные преобразования между целыми и значениями перечисления6 Наиболее интересное применение перечислений - это шкалы. В этом случае каждый к-й элемент перечисления проецируется на значение 2к. Тогда переменная типа "перечисление" интерпретируется как набор битов, над которыми определены, как мы знаем, логические операции. Если необходимо работать с множеством объектов, характеризуемых набором бинарных свойств (свойство присутствует у объекта или нет; например, знает данный программист ОО-концепции или нет), то шкалы - прекрасный инструмент для таких задач..

Linq

В версии языка C# 3.0 появились новые важные механизмы, привлекшие внимание программистов. Механизм, известный как Linq, позволяет языку программирования работать непосредственно с базами данных и Web. Для работы с данными используется типичный язык запросов SQL и XML для Web. Обычно такие возможности поддерживаются с помощью специальных библиотек, обеспечивающих интерфейс к реляционным базам данных или к протоколу HTTP. Оригинальность Linq в том, что он делает все гораздо выразительнее, оставаясь в рамках языка программирования. Например, запрос к базе данных можно выполнить так:

from e in Employees where e.salary > median select e.rank

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

Лексические аспекты

В C# идентификаторы следуют соглашениям, подобным Eiffel. Они могут, однако, начинаться с подчеркивания, хотя эта возможность редко используется в обычных приложениях. Важная разница в том, что идентификаторы чувствительны к регистру: anIdentifier, AnIdentifier и anidentifier - это все различные идентификаторы. Лежащее в основе множество символов - это Unicode.

Комментарии в C# программах могут быть:

  • однострочными: любая часть строки, начинающаяся с //;
  • многострочными: начинающиеся и заканчивающиеся парой символов /* …*/.

Однострочные комментарии, начинающиеся с /// (с добавленным слеш-символом), задают документированный комментарий, записываемый в виде XML-кода. Эти комментарии используются специальным инструментарием для разных целей - документирования, интеллектуальной подсказки.

Библиография

Judith Bishop, Nigel Horspool, C# Concisely, Addison-Wesley, 2003. Введение в основные механизмы C# (для ранних версий языка).

Онлайн документация на сайте:http://msdn.microsoft.com/en-us/vcsharp/default.aspx

Лучшее место для получения детальной спецификации механизмов языка. Сайт также включает ссылки на несколько учебников.

На русском языке: Биллиг В.А. "Основы объектного программирования на C# 3.0", Интернет-Университет. Питер, Лаборатория знаний, 20107 Не могу удержаться от рекламирования собственного учебника, тем более, что он соответствует по духу этой книге..

Ольга Попова
Ольга Попова
Россия
Михаил Окнов
Михаил Окнов
Россия