Система типов
ЧАСТЬ II
ОСНОВЫ КЛАССОВ С#
ГЛАВА 4
Система типов
Эта глава посвящена универсальной системе типов .NET Common Type System (CTS), которая находится в центре Microsoft .NET Framework. CTS определяет не только все типы, но и правила, которым Common Language Runtime (CLR) следует в отношении объявления и использования этих типов приложениями. Вы познакомитесь с типами, доступными разработчикам на С#, и узнаете об особенностях их применения в программах на С#. Мы начнем с изучения концепции, согласно которой в .NET каждый программный элемент является объектом. Далее мы рассмотрим две категории типов: размерные (value types) и ссылочные (reference types). Вы также узнаете, как упаковка (boxing) обеспечивает эффективную работу полностью объектно-ориентированной системы типов. В завершение мы рассмотрим работу приведения типов в С# и приступим к изучению пространств имен.
В большинстве объектно-ориентированных языков есть две отдельных категории типов: базисные (primitive types), т. е. присущие языку, и классы — типы, которые может создать пользователь языка. Как вы уже могли догадаться, к базисным относятся обычно такие простые типы, как символы, строки и числа, тогда как классы чаще представляют собой более сложные конструкции.
Существование двух категорий типов ведет к возникновению массы проблем. Одна из них связана с совместимостью. Допустим, вам потребовалась совокупность значений типа int в традиционной системе, где имеются оба набора типов. При этом нужно будет создать класс, содержащий исключительно значения типа int. А если еще потребуется класс, содержащий совокупность значений типа double, вам придется делать то же самое и для данного типа. Причина в том, что эти базисные типы обычно не имеют ничего общего. Они не являются настоящими объектами и поэтому не происходят от общего базового класса — к каждому из них применяются собственные правила. Аналогичная проблема обнаруживается при попытке в таких традиционных системах задать метод, принимающий аргумент любого типа, поддерживаемого языком. Поскольку базисные типы несовместимы, невозможно задать использование подобного аргумента, если не написать для каждого базисного типа класс-оболочку.
К счастью, в мире .NET и С# такой проблемы уже нет, поскольку в CTS любая сущность — объект. Более того, все объекты косвенно происходят от единого базового класса, определенного в составе CTS. Этот базовый класс — System.Object — будет описан в разделе «Упаковка и распаковка».
Концепция создания языка, где любая сущность является объектом, не нова. Такие попытки предпринимались, например, в SmallTalk. Самым большим недостатком представления всего в виде объектов всегда было снижение производительности. Так, если в SmallTalk попытаться сложить два значения типа double, при этом реально выделяется объект в куче. Нужно ли говорить, что выделение объекта лишь для суммирования двух чисел чрезвычайно малоэффективно.
Перед разработчиками CTS стояла задача создания системы типов, где любая сущность была бы объектом, но система типов при этом работала эффективно. Они решили эту задачу, разделив типы CTS на две категории: размерные (value types) и ссылочные (reference types). Как вы вскоре увидите, эти термины отражают способы выделения памяти и внутреннего функционирования переменных.
Если некоторая переменная имеет размерный тип, она содержит реальные данные. Так что первое правило для размерных типов таково: они не могут быть null. Ниже, например, я на С# выделил память, создав переменную типа System.Int32, который определен в CTS. При этом объявлении происходит не что иное, как выделение в стеке 32-разрядной области.
int i = 32;
Кроме того, при присвоении / значения в выделенное пространство помещается 32-разрядное число.
В С# определено несколько размерных типов, включая перечислители (enumerators), структуры (structures) и примитивы (primitives). Объявляя переменную одного из этих типов, вы каждый раз выделяете в стеке некоторое число байтов, ассоциированных с этим типом, и работаете напрямую с выделенным массивом битов. Кроме того, когда вы передаете переменную размерного типа, передается значение переменной, а не ссылка на лежащий в ее основе объект.
Ссылочные типы похожи на ссылки в C++, где они являются указателями, привязанными к типам (type-safe pointers). Это значит, что ссылка (если она не равна null) — это не просто адрес, который, как вы полагаете, может указывать (а может и не указывать) на определенный объект. Ссылка всегда гарантированно указывает объект заданного типа, уже выделенный в куче. Кроме того, ссылка может быть равна null.
Ниже выделяется значение ссылочного типа (string), но при этом «за кулисами» в куче выделяется значение и возвращается ссылка на него:
string s = "Hello,World";
Как и в случае размерных типов, в С# несколько типов определены как ссылочные: классы, массивы, делегаты (delegates) и интерфейсы. Объявляя переменную одного из этих типов, вы каждый раз выделяете в куче некоторое ассоциированное с этим типом число байт. Но вместо того, чтобы работать с ними напрямую (как в случае размерных типов), вы работаете со ссылкой на выделенный объект.
Как же эти различные категории типов обеспечивают более эффективную работу системы? Это делается с помощью упаковки (boxing). В простейшем случае при упаковке размерный тип преобразуется в ссылочный. В обратном случае ссылочный тип распаковывается (unbox) в размерный.
Замечательно в данной методике то, что объект лишь тогда является объектом, когда это необходимо. Допустим, вы объявляете переменную типа System.Int32. Для нее выделяется память в стеке. Вы можете передавать эту переменную любому методу, определенному в качестве принимающего аргументы типа System.Object, а также обращаться к любому из ее членов, к которому у вас есть доступ. Поэтому вы воспринимаете и ощущаете ее как объект. Но в реальности это всего 4 байта в стеке.
Только когда вы пытаетесь использовать эту переменную согласно правилам, определенным интерфейсом базового класса System.Object, система автоматически упаковывает переменную, в результате чего она становится ссылочным типом и может быть использована так же, как любой объект. Упаковка — это механизм, посредством которого в С# любая сущность может быть представлена в виде объекта. Это позволяет избежать издержек, неизбежных в том случае, если б всякая сущность на самом деле была объектом. Обратимся к примерам:
int foo = 42; // Размерный тип.
object bar = foo; // Переменная foo упакована в bar.
В первой строке этого кода мы создавали переменную (foo) типа int. Как вам известно, int является размерным типом (поскольку это базисный тип). Во второй строке компилятор обнаружит, что переменная foo скопирована в ссылочный тип, представленный переменной bar. При этом компилятор добавит код MSIL, необходимый для упаковки этой переменной.
А теперь выполним явное приведение типов, чтобы преобразовать bar обратно в размерный тип:
int foo = 42; // Размерный тип.
object bar = foo; // Переменная foo упакована в bar.
int foo2 = (int)bar; // Распаковка и приведение к типу int.
При упаковке (т. е. преобразовании из размерного типа в ссылочный) явного приведения типов не требуется. Однако при распаковке — преобразовании из ссылочного типа в размерный — приведение типов необходимо. Это так, потому что в случае распаковки объект может быть приведен к любому типу. Преобразование позволяет компилятору проверить, возможно ли приведение для заданного типа переменной. Поскольку приведение типов подчинено строгим правилам, определяемым CTS, мы рассмотрим этот случай подробнее в разделе «Приведение типов».
Как я уже говорил, в конечном счете все типы происходят от типа System.Object, что позволяет гарантировать наличие у каждого типа минимального набора функциональных возможностей. Все типы получают «бесплатно» четыре открытых метода (табл. 4-1).
Табл. 4-1. Открытые методы типа System.Object.
Метод |
Описание |
bool Equals() |
Сравнивает две ссылки на объекты в период выполнения,
чтобы определить, указывают ли они в точности один и тот же
объект. Если две переменные ссылаются на один и тот же объект,
возвращается true. В случае размерных типов (см. о них
следующий раздел) этот метод возвращает true, если типы
переменных идентичны и их значения равны. |
int GetHashCodeO |
Возвращает заданный для объекта хзш-код. Хэш-функции
используются в реализации класса, когда хэш-код объекта нужно
поместить в хэш-таблицу для повышения производительности.
|
Type GetType() |
Используется с методами отражения (см. о них главу 16) для
получения информации о типе данного объекта. |
string ToString |
Используется по умолчанию для получения имени объекта. Его
можно переопределить в производных классах, чтобы они
возвращали понятное пользователю текстовое представление
объекта. |
Ниже описаны защищенные методы System.Objeci (табл. 4-2). Табл. 4-2. Защищенные методы типа System.Object.
Метод |
Описание |
void Finalize() |
Вызывается в период выполнения для
освобождение ресурсов перед сбором мусора. Этот метод можно
вызывать, а можно и не делать этого. Поэтому не помещайте в
него подлежащий исполнению код. Это правило выливается в нечто
под названием детерминированное завершение
(deterministic finalization), о котором подробнее см.
главу 5. |
Object MemberwiseClone |
Представляет ограниченную копию
(shallow copy) объекта. Под этим я понимаю копию объекта,
содержащую ссылки на другие объекты, но не копии этих
объектов. Если ваши классы должны поддерживать полную копию
(deep copy), которая действительно включает копии
объектов, на которые она ссылается, то вам нужно реализовать
интерфейс ICloneable и самому вручную производить
клонирование или копирование. |
В то время как CTS отвечает за определение типов, которые могут использоваться в различных языках .NET, для большинства языков было решено реализовать псевдонимы для этих типов. Например, 4-байтовое
целочисленное значение, представляется типом System.Int32, который определен в CTS. В С# для этого типа определен псевдоним int. Использование этих методик равноценно. Ниже приводится список различных типов, определенных в CTS, и их псевдонимов в С# (табл. 4-3).
Табл. 4-3. Типы, определенные в CTS, и их псевдонимы.
Тип СТS |
Имя псевдонима С# |
Описание |
System. Object |
object |
Базовый класс для всех типов CTS
|
System. String |
string |
Строка |
System. SByte |
sbyte |
8 -разрядный байт со знаком |
System. Byte |
byte |
8 -разрядный байт без знака |
System. Ы16 |
short |
16-разрядное число со знаком |
System. UM16 |
ushort |
16-разрядное число без знака |
System. Int32 |
int |
32-разрядное число со знаком |
System. UInt32 |
uint |
32-разрядное число без знака |
System. Int64 |
long |
64-разрядное число со знаком |
System. UInt64 |
ulong |
64-разрядное число без знака |
System.Char |
char |
16-разрядный символ Unicode |
System. Single |
float |
32-разрядное число с плавающей
точкой стандарта IEEE |
System. Double |
double |
64-разрядное число с плавающей
точкой стандарта IEEE |
System. Boolean |
boo/ |
Булевское значение (true/false)
|
System. Decimal |
decimal |
128-рзрядный тип данных с точностью
до 28 или 29 знака — используется главным образом в финансовых
приложениях, для которых требуется высокая точность.
|
Сейчас мы рассмотрим один из самых важных аспектов типов — приведение. Допустим, существует базовый класс под названием Employee и производный класс ContractEmployee. Следующий код будет работать, поскольку всегда подразумевается восходящее приведение (upcast) производного класса к его базовому классу:
class Employee { }
class ContractEmployee : Employee { }
class CastExamplel
{
public static void Main () {
Employee e = new ContractEmployeeQ; } }
А вот такой код недопустим, так как компилятор не предоставляет неявное нисходящее приведение (downcast).
class Employee { }
class ContractEmployee : Employee { }
class CastExample2 {
public static void Main ()
{
ContractEmployee ce = new EmployeeQ; // He будет
// компилироваться.
} }
Причина различного поведения этих фрагментов кода, описанная в главе 1, связана с понятием заменяемости (substitutability). Правила заменяемости гласят: производный класс может быть использован вместо своего базового класса. Поэтому объект типа ContractEmployee всегда можно использовать вместо объекта Employee. Потому и компилируется код первого примера.
Однако вы не сможете выполнить нисходящее приведение объекта типа Employee к объекту типа ContractEmployee, поскольку нет гарантии, что этот объект поддерживает интерфейс, определенный классом ContractEmployee. Поэтому в случае нисходящего приведения используется явное приведение:
class Employee { }
class ContractEmployee : Employee { }
class CastExampleS {
public static void Main ()
{
// Нисходящее приведение не сработает.
ContractEmployee ce = (ContractEmployee)new Employee(); } }
А давайте обманем CTS путем явного приведения базового класса к производному:
class Employee { }
class ContractEmployee : Employee { }
class CastExample4 {
public static void Main ()
<
Employee e = new EmployeeO; ContractEmployee с = (ContractEmployee)e; } }
Эта программа компилируется, но генерирует исключение периода выполнения. Здесь важны два момента. Во-первых, ошибка периода компиляции не возникает, так как е на самом деле может быть объектом ContractEmployee, приведенным к базовому классу. Поэтому истинная природа объекта, приведенного к базовому классу, не может быть распознана до периода выполнения. Во-вторых, CLR определяет типы объектов в период выполнения. Распознав неверное приведение, CLR генерирует исключение System.InvalidCastException.
Есть еще один способ приведения объектов — ключевое слово «5. Преимущество использования as вместо собственно приведения в том, что в случае неверного приведения вам не придется беспокоиться о возникающем исключении. Вместо этого вы получите null, например:
using System;
class Employee { >
class ContractEmployee : Employee { }
class CastExampleS {
public static void Main ()
{
Employee e = new EmployeeO;
Console.WriteLine("e = {0}",
e == null ? "null" : e.ToStringO);
ContractEmployee с = e as ContractEmployee;
Console.WriteLine("c = {0}",
с == null ? "null" : e.ToStringO); > }
Запустив этот пример, вы увидите такой результат:
c:>CastExample5 e = Employee с = null
Способность сравнивать объект с null означает, что вы ничем не рискуете, используя пустые объекты. Фактически, если в приведенном выше примере вызвать метод System.Object для объекта с, то CTS сгенерирует исключение System.NullReferenceException.
Пространства имен (namespaces) используются в С#-приложениях для определения области видимости. Объявив пространство имен, разработчик может дать С#-приложению иерархическую структуру, основанную на семантически связанных группах типов и других (вложенных) пространствах имен. В формировании одного пространства имен могут участвовать несколько файлов исходного кода. Это обстоятельство позволяет при компоновке единого пространства имен из нескольких классов определять каждый класс в собственном файле исходного кода. Программист, использующий созданные вами классы, получит доступ ко всем классам в пространстве имен через ключевое слово using.
ПРИМЕЧАНИЕ
Там, где это возможно, рекомендуется использовать имя компании в качестве корня пространства имен, чтобы гарантировать его уникальность. О правилах именования см. главу 3.
Иногда у вас может возникнуть желание использовать для некоторого типа полностью квалифицированное имя в форме пространство_имен. -тип. Однако это скорее всего утомительно и порой не обязательно. Ниже мы используем объект Console, существующий в пространстве имен System.
class Usingl {
public static void Main()
{
System.Console.WriteLine("test"); } >
А если известно, что объект Console существует только в пространстве имен System! Ключевое слово using позволяет задать порядок поиска пространств имен. Так что, встретив неквалифицированный тип, компилятор может обратиться к указанному списку пространств имен для поиска этого типа. В следующем примере разработчику не обязательно каждый раз указывать имя пространства имен (System), чтобы компилятор мог найти в нем объект Console:
using System;
class Using2 {
public static void Main()
{
Console.WriteLine("test");
} }
Создавая настоящие приложения с несколькими сотнями вызовов объектов System, вы быстро увидите преимущество положения, при котором вы не обязаны предварять именем пространства имен каждую ссылку на объект.
Имя класса нельзя задавать с помощью ключевого слова using. Поэтому такой код неверен:
using System.Console; // Недопустимо!
class UsingS {
public static void Hain()
{
WriteLineC'tesf);
} }
Однако вы можете выйти из положения, используя ключевое слово using для создания псевдонима:
using console = System.Console;
class Using4 {
public static void Main()
<
console. WriteLineC'tesf);
> }
Это дает особенно много преимуществ, когда вложенные пространства имен объединены с длинными именами классов, что делает написание кода весьма утомительным занятием.
Одна из ключевых функций любого языка или среды периода выполнения — поддержка типов. Язык, в котором доступно ограниченное число типов или который ограничивает возможности программиста по расширению встроенных типов языка, на роль «долгожителя» может не рассчитывать. Однако наличие унифицированной системы типов дает также множество других выгод.
CTS играет важную роль в обеспечении способности к взаимодействию языков, так как она определяет набор типов, которые должен поддерживать компилятор .NET, чтобы обеспечивать взаимодействие с другими языками. Сама CTS определена в спецификации CLS (Common Language Specification). CLS определяет единый набор правил для каждого компилятора .NET, гарантируя, что каждый компилятор будет выдавать код, согласованно взаимодействующий с CLR. Согласно требованиям CLS, компилятор должен поддерживать некоторые типы, определенные в CTS. Все языки .NET используют единую систему типов. Это выгодно, так как гарантирует бесшовное взаимодействие объектов и типов, создаваемых на различных языках. Благодаря этой комбинации CTS/CLS взаимодействие программ на разных языках — это уже не мечта программистов, а реальность.
Как я уже говорил, важная характеристика CTS — иерархия объектов с единым корнем. В .NET Framework каждый тип системы происходит от базового класса System.Object. Подход, использующий единый базовый класс, — важное отличие от языка C++, в котором нет базового класса для всех классов. Он рекомендован теоретиками ООП и реализован в большинстве объектно-ориентированных языков, формирующих главное направление этой технологии. Возможно, вы не увидите всех преимуществ иерархии с единым корнем сразу. Но со временем вы задумаетесь: как же велась разработка языков до того, как была введена поддержка этого типа иерархии?
Иерархия объектов с единым корнем является ключевой для унифицированной системы типов, поскольку гарантирует наличие общего интерфейса у каждого объекта в иерархии, и поэтому всякая сущность в составе этой иерархии в конечном счете принадлежит к одному базовому типу. Один из самых серьезных минусов C++ — отсутствие поддержки подобной иерархии. Рассмотрим простой пример.
Допустим, создав иерархию объектов на C++ на основе собственного базового класса CFoo, вы хотите интегрировать ее с другой иерархией, все объекты которой происходят от базового класса СВаг. В этом примере интерфейсы иерархий объектов несовместимы, поэтому их интеграция заставит попотеть. Чтобы справиться с этой задачей, вам придется использовать класс-оболочку или множественное наследование. В случае иерархии с одним корнем совместимость не проблема, так как интерфейс объектов един (унаследованный от System.Object). В результате вы знаете, что у любого и каждого объекта вашей иерархии — и, что важнее всего, в иерархиях кода .NET сторонних разработчиков — всегда будет минимальный набор функциональности.
В завершение упомяну такое полезное свойство CTS, как безопасность типов. Безопасность типов гарантирует, что типы являются именно тем, за что они себя выдают, и что над некоторым типом можно выполнить лишь подходящие действия. Безопасность типов предоставляет несколько преимуществ и возможностей (об этом — чуть дальше), большинство из которых обеспечиваются иерархией объектов с единым корнем.
- Каждая ссылка на объект типизирована, как и объект, на который она ссылается. CTS гарантирует, что ссылка всегда указывает именно на то, на что она должна указывать.
- Поскольку CTS отслеживает каждый тип в системе, систему нельзя обмануть, выдав один тип за другой. Очевидно, это особенно важно для распределенных приложений, где приоритетом является защита.
- Каждый тип отвечает за определение доступности своих членов, задавая модификатор доступа. Это делается для каждого члена в отдельности. Может быть задан любой вид доступа (если член объявлен как public), доступ может быть ограничен кругом унаследованных классов (если член объявлен как protected) или вовсе запрещен (при объявлении члена как private). Можно также разрешить доступ к члену только другим типам в составе текущего компилируемого модуля, если объявить его как internal. Подробнее о модификаторах доступа см. главу 5.
Подведем итоги
CTS (Common Type System) — важная особенность .NET Framework. CTS определяет правила системы типов, которым приложения обязаны следовать, чтобы корректно работать в CLR. Типы CTS делятся на ссылочные и размерные. Для определения области видимости приложения можно использовать пространства имен. Среди выгод, предоставляемых CTS, следует отметить возможность взаимодействия языков, иерархию объектов с единым корнем и безопасность типов. В С# типы могут быть преобразованы с помощью упаковки и распаковки, а приведение типов позволяет создавать совместимые типы, имеющие совместимые характеристики и функциональность.