Основные сведения о членах и типах
Члены типа
В типе можно определить следующие члены:
- Константа - идентификатор, определяющий некоторую постоянную величину; используются, чтобы упростить чтение кода и для удобства сопровождения и поддержки; константы связаны с типом, а не с экземпляром; на логическом уровне всегда являются статическими членами.
- Поле - значение данных для чтения и/или записи; может быть статическим, тогда оно является частью состояния типа, или экземплярным - тогда является состоянием конкретного объекта; доступ к полям стоит ограничивать.
- Конструктор экземпляров - служит для инициализации полей при создании экземпляра.
- Конструктор типа - служит для инициализации статических полей типа.
- Метод - функция, выполняющая операции, которые заменяют или запрашивают состояние типа (статический метод) или объекта (экземплярный метод).
- Перегруженный оператор - определяет поведение, которое необходимо проделать с объектом при применении к нему конкретного оператора; не входит в CLS.
- Оператор преобразования - метод, задающий порядок явного или неявного преобразования объекта из одного типа в другой; не входит в CLS.
- Свойство - механизм, позволяющий применить простой синтаксис для получения или установки части логического состояния типа или объекта с контролем логической целостности; бывают необобщёнными и обобщёнными (индексатор - редкий случай, в основном в классах коллекций); в метаданных создаёт два метода с префиксами
get_иset_. - Событие - механизм, позволяющий типу отправлять уведомления статическим или экземплярным методам; события инициируются в ответ на изменение состояния типа или объекта; состоит из двух методов: регистрации и отмены подписки на событие; использует поле-делегат для управления набором зарегистрированных методов; в метаданных создаёт само событие, а также два метода с префиксами
add_иremove_. - Тип - определяет вложенные типы; применяется для разбиения большого, сложного типа на небольшие блоки с целью упростить реализацию.
Видимость типа
При определении типа с видимостью в рамках файла, а не другого типа, его можно сделать открытым (public) или внутренним (internal). По умолчанию компилятор C# делает тип внутренним, доступным только внутри сборки
Дружественные сборки
Иногда случается ситуация, когда необходимо сделать типы из одной сборки видимыми для другой сборки, при этом не делая их общедоступными. Для этого есть механизм дружественных сборок (friend assemblies). Дружественная сборка определяется именем и открытым ключом.
Доступ к членам типов
При определении члена типа можно указать модификатор доступа к члену. Модификаторы определяют, на какие члены можно ссылаться из кода. В CLR имеется свой набор возможных модификаторов, но в каждом языке свой синтаксис и термины. Ниже представлено шесть модификаторов: от максимального ограничения доступности до минимального.
| CLR | C# | Описание |
|---|---|---|
| Private (закрытый) | private | Доступен только внутри типа и вложенных типов |
| Family (родовой) | protected | Доступен только методам в определяющем типе (прим. здесь и далее определяющий тип ВКЛЮЧАЕТ вложенные типы) или в одном из производных типов независимо от сборки |
| Family and Assembly (родовой и сборочный) | private protected (начиная с C# 7.2) | Доступен в определяющем типе и производных типах в определяющей сборке |
| Assembly (сборочный) | internal | Доступен в определяющей сборке |
| Assembly or Family (сборочный или родовой) | protected internal | Доступен во вложенном и производном типах, а также в определяющей сборки |
| Public (открытый) | public | Доступен всем методами во всех сборках |
Верификация IL-кода гарантирует правильность обработки модификаторов доступа к членам в период выполнения, даже если компилятор проигнорировал эту проверку.
Если модификатор доступа не указан явно, то компилятор применяет наиболее строгий из всех - private. Начиная с C# 8 можно явно указывать модификаторы доступа к членам интерфейса, теперь они не обязательно должны быть открытыми, как раньше.
Компилятор C# требует, чтобы у членов базового и производного типов были одинаковые модификаторы доступа. При наследовании позволено снижать, но не повышать ограничения доступности члена, так как разработчик мог бы легко получить доступ к методу через приведение к базовому типу.
Статические классы
Существуют классы, не предназначенные для создания экземпляров. В сущности, они нужны для группировки логически связанных членов. В C# такие классы определяются ключевым словом static. Его разрешается применять только к классам, но не структурам, так как CLR всегда разрешает создавать экземпляры значимых типов и нет способа обойти это ограничение.
Компилятор накладывает на статический класс ряд ограничений:
- Класс должен быть прямым потомком
System.Object- наследование от любого другого класса не имеет смысла, так как наследование применимо к объектам, а создать экземпляр статического класса невозможно. - Класс не должен реализовывать никаких интерфейсов, так как методы интерфейса можно вызвать через экземпляры класса.
- В классе можно определять только статические члены. Любые экземплярные члены вызовут ошибку компиляции.
- Класс нельзя использовать в качестве поля, параметра метода или локальной переменной, так как это подразумевает существование экземпляра. В этом случае компилятор вернёт сообщение об ошибке.
Частичные классы, структуры и интерфейсы
Ключевое слово partial говорит компилятору C#, что исходный код типа может располагаться в нескольких файлах. Компилятор собирает их вместе на этапе компиляции, так как CLR работает с полными определениями типов. Для использования частичных классов есть три основные причины:
- Управление версиями. Частичные классы используют, когда над одним типом могут трудиться несколько разработчиков. Разделение на частичные классы может избежать конфликтов.
- Разделение типа на логические модули внутри файла. Иногда требуется создать один тип для решения разных задач. Тогда этот тип можно разделить и в каждой части реализовать аспект, который необходим в данном случае. Это позволяет упростить наблюдение за членами, обеспечивающими единую функциональность и объединёнными в группу.
- Разделители кода. Иногда часть кода генерируется автоматически при создании проекта. И чтобы этот код не смешивался с исходным кодом разработчика, его выносят в отдельный частичный класс.
Частичные типы реализуются только компилятором C#, так что все файлы с исходным кодом должны быть написаны на одном языке.
Компоненты, полиморфизм и версии
ООП существует уже давно. В поздние 70-е и ранние 80-е годы приложения были гораздо меньше и разрабатывались в одной компании Современные же приложения состоят из компонентов, разработанных многими компаниями. Компоненты объединяются в приложение в рамках ООП.
При компонентной разработке (Component Software Programming, CSP) идеи ООП используются на уровне компонентов. Вот основные их свойства:
- Компонент (сборка в .NET) можно публиковать.
- Компоненты уникальны и идентифицируются по имени, версии, региональным стандартам и открытому ключу.
- Компонент сохраняет свою уникальность (код одной сборки никогда статически не связывается с другой сборкой, в .NET используется динамическое связывание).
- В компоненте всегда чётко указана зависимость от других компонентов (ссылочные таблицы метаданных).
- В компоненте документированы его классы и члены. В C# даже разрешается включать в код компонента XML-документацию.
- В компоненте определяются требуемые разрешения на доступ. Для этого в CLR существует механизм защиты доступа к коду (Code Access Security, CAS).
- Опубликованный компонентном интерфейс (объектная модель) не изменяется в его служебных версиях. Служебной версией (servicing) называют новую версию компонента, обратно совместимую с оригинальной. Обычно служебная версия содержит исправления ошибок, но не новые зависимости или разрешения безопасности.
В компонентном программировании большое внимание уделяют управлению версиями. В компоненты вносятся изменения, они поставляются в разное время. Это существенно усложняет компонентное программирование по сравнению с классическим ООП, где всё приложение пишет, тестирует и поставляет одна компания.
В .NET номер версии состоит из четырёх частей: основного (major), дополнительного (minor), номер построения (build) и номер редакции (revision). В случае изменения функциональности меняются основной и дополнительный номера, в случае исправлений - построения и редакции.
Проблемы управления версиями возникают, когда тип, определённый в одном компоненте, является базовым для типа в другом компоненте. Эти проблемы особенно характерны для полиморфизма, когда в производном типе переопределяются виртуальные методы.
В C# есть пять ключевых слов, которые непосредственно связаны с управлением версиями.
Модификаторы наследования
| Ключевое слово C# | Тип | Метод/Свойство/Событие | Константа/Поле |
|---|---|---|---|
| abstract | Нельзя создать экземпляр этого типа. | Член необходимо переопределить и реализовать в производном типе - после этого можно создать экземпляр производного типа. | (запрещено) |
| virtual | (запрещено) | Член можно переопределять в производном типе | (запрещено) |
| override | (запрещено) | Член переопределяется в производном типе | (запрещено) |
| sealed | Тип нельзя использовать в качестве базового. | Член нельзя переопределить в производном типе. Можно применить только к методу, переопределяющему виртуальный метод. | (запрещено) |
| new | Означает, что член никак не связан с похожим членом из базового класса (применяется по умолчанию при описании члена с похожим названием) | ||
Вызов виртуальных методов, свойств и событий в CLR
Методы содержат код, выполняющий некоторые действия над типом (статические методы) или над объектом (экземплярные методы). У каждого метода есть имя, сигнатура и возвращаемый тип (иногда может быть пустым, void). У типа может быть несколько методов с одинаковым именем, но с разным числом параметров или с разными возвращаемыми значениями. Можно также объявить метод с одними и теми же именем и параметрами, но с разными возвращаемыми значениями (хотя большинство языков это не используют, за исключением IL).
Есть класс с тремя различными вариантами методов:
internal class Employee
{
// Невиртуальный экземплярный метод
public Int32 GetYearsEmployed { ... }
// Виртуальный метод (виртуальный - значит, экземплярный)
public virtual String GetProgressReport { ... }
// Статический метод
public static Employee Lookup(String name) { ... }
}
При компиляции этого кода компилятор помещает три записи в таблицу определений методов сборки. Каждая запись содержит флаги, указывающие, является ли метод экземплярным, виртуальным или статическим.
При компиляции кода, ссылающегося на эти методы, компилятор проверяет флаги, чтобы определить, какой IL-код необходимо вставить для корректного вызова. В CLR есть две инструкции для вызова методов:
- Инструкция
callиспользуется для вызова любых методов. Если вызвался статический метод, то необходимо указать тип, где определяется метод. При вызове экземплярного или виртуального метода необходимо указать переменную, ссылающуюся на объект (подразумевается, что переменная неnull). Сам тип переменной указывает, в каком типе определён необходимый метод. Если метод не определён в типе, то проверяются базовые типы. Инструкция часто используется для невиртуального вызова виртуальных методов. - Инструкция
callvirtиспользуется для вызова экземплярных и виртуальных методов. При вызове необходимо указать переменную. Если с помощью этой инструкции вызывается невиртуальный метод, тип показывает, где определён метод. При вызове виртуального метода, CLR определяет настоящий тип объекта и вызывает метод полиморфно. При компиляции такого метода происходит проверка наnull, из-за чего метод работает чуть медленнее.
Компилятор C# часто использует именно инструкцию callvirt для вызова невиртуальных экземплярных методов, чтобы спровоцировать проверку на null.
Компиляторы стремятся использовать инструкцию call для вызова методов значимого типа, потому что они запечатаны. В этом случае полиморфизм невозможен, как и нуллабильность этого объекта по природе его типа. Наконец, для виртуального вызова виртуального метода значимого типа необходима упаковка, что увеличит частоту сборки мусора и снизит производительность.
При проектировании типов стоит стремиться свести к минимуму число виртуальных методов, так как они замедляют приложение и усложняют контроль версий. Виртуальный стоит делать только наиболее функциональный метод в типе.
Разумное использование видимости типов и модификаторов доступа к членам
Автор считает, что компилятором стоит по умолчанию делать классы запечатанными по следующим причинам:
- Управление версиями. Если класс изначально сделан запечатанным, то его впоследствии можно сделать незапечатанным, не нарушая совместимости. Однако обратное невозможно. Кроме того, если в незапечатанном классе определены незапечатанные виртуальные методы, необходимо сохранять порядок вызовов, иначе в будущем возникнут проблемы с производными типами.
- Производительность. При вызове виртуального метода в запечатанном типе, JIT-компилятор может задействовать невиртуальный вызов, так как у типа не может быть наследников.
- Безопасность и предсказуемость. Состояние класса должно быть надёжно защищено. Производный тип может изменять состояние базового через открытые методы и свойства.
Несколько правил проектирования типов:
- Если класс не предназначен для наследования, его стоит запечатывать.
- Все поля класса должны быть закрытыми.
- Методы, свойства и события стоит объявлять закрытыми и невиртуальными. Естественно, часть методов должна быть открыта для работы с классом, но лучше не делать их защищёнными или внутренними. Хотя защищённый или внутренний член всё-таки лучше виртуального, поскольку последний предоставляет производному классу больший контроль над своим поведением.
- В ООП есть принцип: "Лучший метод борьбы со сложностью - добавление новых типов". Однако новые типы стоит создавать рядом, а не внутри, чтобы не усложнять обращение к вложенным типам.
Работа с виртуальными методами при управлении версиями типов
Описывается пример, когда тип, написанный в одной компании, является производным от типа, написанного в другой компании. Здесь же описываются все те действия, через которые приходится проходить разработчикам при изменениях в исходном коде обоих типов.
