Прочитав эту главу, вы научитесь:
• объяснять разницу между типом значений и типом ссылок;
• изменять способ передачи аргументов в качестве параметров методов с помощью ключевых слов ref и out;
• превращать значение в ссылку, используя упаковку (boxing);
• превращать ссылку обратно в значение, используя распаковку (unboxing) и приведение типов (casting).
В главе 7 «Создание классов и объектов и управление ими» было показано, как объявляются свои собственные классы и как создаются объекты с помощью использования ключевого слова new. Там же было показано, как с помощью конструктора инициализируется объект. В данной главе вы узнаете, чем характеристики простых типов, таких как int, double и char, отличаются от характеристик типов классов.
Большинство элементарных типов, встроенных в C#, например int, float, double и char (но не string — по причинам, которые вскоре будут рассмотрены), обобщенно называются типами значений. У этих типов фиксированный размер, и когда вы объявляете переменную как тип значения, компилятор создает код, занимающий блок памяти, достаточный по размеру для хранения соответствующего значения. Например, объявление int-переменной заставляет компилятор выделить для хранения целочисленного значения 4 байта памяти (32 бита). Инструкция, присваивающая значение (например, 42) int-переменной, приводит к тому, что значение копируется в этот блок памяти.
Такие типы классов, как Circle, рассмотренный в главе 7, обрабатываются по-другому. Когда объявляется Circle-переменная, компилятор не создает код, распределяющий блок памяти, размер которого достаточен для хранения значения типа Circle, а просто отводит небольшой участок памяти, где потенциально может содержаться адрес другого блока памяти (или ссылка на него), в котором содержится Circle-значение (адрес, указывающий место элемента в памяти). Память под реальный Circle-объект выделяется, только когда для создания объекта используется ключевое слово new. Класс является примером ссылочного типа. В ссылочных типах содержатся ссылки на блоки памяти. Для создания эффективных программ на C#, которые в полной мере используют среду Microsoft .NET Framework, нужно разобраться в том, чем типы значений отличаются от ссылочных типов.
ПРИМЕЧАНИЕ Тип string в C# фактически является классом. Дело в том, что для строки не существует стандартного размера (различные строки могут содержать разное количество символов) и динамическое выделение памяти под строку в ходе выполнения программы работает гораздо эффективнее статического выделения в ходе компиляции. Описание ссылочных типов, таких как классы, приведенное в этой главе, применимо также к типу string. Фактически ключевое слово string в C# является псевдонимом класса System.String.
Рассмотрим ситуацию объявления переменной по имени i с типом значения int и присваивания ей значения 42. Если объявить еще одну переменную по имени copyi с типом значения int, а затем присвоить переменную i переменной copyi, то copyi будет содержать точно такое же значение, что и переменная i (42). Но даже при том что copyi и i содержат одно и то же значение, это значение 42 содержат два блока памяти: один для i, другой для copyi. Если вы измените значение i, значение copyi не изменится. Давайте посмотрим, как выглядит соответствующий код:
int i = 42; // объявление и инициализация i
int copyi = i; /* copyi содержит копию данных, имеющихся в i:
как i, так и copyi содержат значение 42 */
i++; /* увеличение i на единицу не влияет на copyi;
i теперь содержит 43, а copyi — по-прежнему 42 */
Эффект, получаемый от объявления переменной c в качестве типа класса, такого как Circle, совершенно иной. При объявлении c в качестве Circle-переменной c может ссылаться на Circle-объект; фактическим значением, содержащимся в c, является адрес Circle-объекта в памяти. Если объявить еще одну переменную по имени refc (также в качестве Circle-переменной) и присвоить ей значение переменной c, в refc будет содержаться копия точно такого же адреса, что и в c. Иными словами, будет существовать только один Circle-объект и теперь на него будут ссылаться обе переменные, как refc, так и c. Соответствующий пример кода выглядит следующим образом:
Circle c = new Circle(42);
Circle refc = c;
Оба примера показаны на рис. 8.1. Знак «эт» (@) в Circle-объектах обозначает ссылку, в которой содержится адрес в памяти.
Рис. 8.1
Это различие играет весьма важную роль. В частности, оно означает, что поведение параметров метода зависит от того, к каким типам они относятся: типам значений или ссылочным типам. Это различие будет изучено при выполнении следующего упражнения.
Копирование ссылочных типов и закрытость данных
Если нужно скопировать содержимое Circle-объекта по имени c в другой Circle-объект по имени refc, то вместо простого копирования ссылки нужно сделать так, чтобы объект refc ссылался на новый экземпляр класса Circle, а затем скопировать данные из c в refc, причем отдельно скопировать каждое поле:
Circle refc = new Circle();
refc.radius = c.radius; // Не пытайтесь это делать
Но если какие-либо элементы класса Circle являются закрытыми (такими, как поле radius), то скопировать их будет невозможно. В качестве альтернативы можно сделать данные в закрытых полях доступными, выставив их в качестве свойств, а затем воспользоваться этими свойствами для чтения данных из объекта по имени c в объект по имени refc. Как это делается, будет показано в главе 15 «Реализация свойств для доступа к полям».
Кроме этого, класс может предоставить метод Clone, возвращающий еще один экземпляр того же класса, но наполненный точно такими же данными. Метод Clone будет иметь доступ к закрытым данным внутри объекта и может копировать эти данные непосредственно в другой экземпляр того же класса. Например, метод Clone для класса Circle может быть определен следующим образом:
class Circle
{
private int radius;
// Конструкторы и другие методы опущены
...
public Circle Clone()
{
// Создание нового Circle-объекта
Circle clone = new Circle();
// копирование закрытых данных из этого объекта в клон
clone.radius = this.radius;
// Возвращение нового Circle-объекта, содержащего скопированные
данные
return clone;
}
}
Этот подход не вызывает затруднений, если все закрытые данные состоят из значений, но если одно или несколько полей сами по себе являются ссылочными типами (например, класс Circle может быть расширен, чтобы в нем мог содержаться Point-объект из главы 7, указывающий позицию окружности Circle на графическом изображении), этим ссылочным объектам также необходимо предоставить метод Clone, в противном случае метод Clone класса Circle просто скопирует ссылку на эти поля. Этот процесс известен как создание углубленной копии. Иной подход, при котором метод Clone просто копирует ссылки, известен как создание поверхностной копии.
Предыдущий пример кода вызывает также весьма интересный вопрос: насколько же закрыты закрытые данные? Ранее вы уже видели, что ключевое слово private делает поле или метод недоступными за пределами класса. Но это не значит, что к нему можно получить доступ из одного-единственного объекта. Если создать два объекта одного и того же класса, каждый из них может обращаться к данным, закрытым внутри кода для этого класса. Как бы странно это ни звучало, но факт остается фактом: работа таких методов, как Clone, зависит от этого свойства. Инструкция clone.radius = this.radius; работает только потому, что закрытое поле radius в объекте clone доступно из текущего экземпляра класса Circle. Следовательно, закрытость (private) означает «собственность класса», а не «собственность объекта». Но не нужно путать private со static. Если просто объявить поле закрытым (private), то каждый экземпляр класса получит свои собственные данные. Если поле объявлено статическим (static), то каждый экземпляр класса использует одни и те же данные совместно с другими экземплярами этого же класса.
Откройте в Microsoft Visual Studio 2015 проект Parameters, который находится в папке \Microsoft Press\VCSBS\Chapter 8\Parameters вашей папки документов. Проект содержит три файла с кодом на C#: Pass.cs, Program.cs и WrappedInt.cs.
Выведите в окно редактора файл Pass.cs. В нем определен класс по имени Pass, который пока что не содержит ничего, кроме комментария // TODO:.
СОВЕТ Не забудьте, что для обнаружения всех имеющихся в решении комментариев TODO можно воспользоваться окном Список задач.
Вместо комментария // TODO: добавьте к классу Pass открытый статический метод по имени Value. Этот метод должен получить один int-параметр (с типом значения) по имени param и иметь возвращаемый тип void. Тело метода Value, выделенное в следующем примере кода жирным шрифтом, будет просто присваивать значение 42 переменной param:
namespace Parameters
{
class Pass
{
public static void Value(int param)
{
param = 42;
}
}
}
ПРИМЕЧАНИЕ Чтобы упражнение не усложнилось, этот метод определен с использованием ключевого слова static. Метод Value можно вызвать непосредственно в отношении класса Pass без предварительного создания нового Pass-объекта. Принципы, проиллюстрированные в этом упражнении, применяются точно так же и к методам экземпляров.
Выведите в окно редактора файл Program.cs, а затем найдите метод doWork класса Program. Метод doWork вызывается методом Main, когда программа начинает работу. Как говорилось в главе 7, вызов метода заключен в блок try, за которым следует обработчик исключения.
Добавьте к методу doWork четыре инструкции, выполняющие следующие задачи.
• Объявление локальной int-переменной по имени i и ее инициализация нулевым значением.
• Запись значения переменной i в консоль с помощью метода Console.WriteLine.
• Вызов Pass.Value с передачей i в качестве аргумента.
• Повторная запись значения i в консоль.
Благодаря вызовам Console.WriteLine до и после вызова Pass.Value вы сможете увидеть, действительно ли вызов Pass.Value изменяет значение переменной i. Окончательно метод doWork должен приобрести следующий вид:
static void doWork()
{
int i = 0;
Console.WriteLine(i);
Pass.Value(i);
Console.WriteLine(i);
}
Щелкните в меню Отладка на пункте Запуск без отладки, чтобы выполнить сборку и запуск программы. Убедитесь, что значение 0 записано в консоль дважды. Инструкция присваивания внутри метода Pass.Value, обновляющая параметр и устанавливающая для него значение 42, использует копию переданного аргумента, а на исходный аргумент i это абсолютно не влияет.
Нажмите Ввод и закройте приложение.
А теперь посмотрите, что произойдет, когда передается int-параметр, заключенный в классе.
Выведите в окно редактора файл WrappedInt.cs. В нем содержится класс WrappedInt, в котором нет ничего, кроме комментария // TODO:.
Добавьте к классу WrappedInt выделенное в следующем примере кода жирным шрифтом открытое поле экземпляра с именем Number, имеющее тип int:
namespace Parameters
{
class WrappedInt
{
public int Number;
}
}
Выведите в окно редактора файл Pass.cs. Добавьте к классу Pass открытый статический метод по имени Reference. Этот метод должен принимать единственный WrappedInt-параметр по имени param и иметь возвращаемый тип void. В теле метода Reference значение 42 должно присваиваться param.Number:
public static void Reference(WrappedInt param)
{
param.Number = 42;
}
Выведите в окно редактора файл Program.cs. Закомментируйте существующий код в методе doWork и добавьте четыре инструкции, выполняющие следующие задачи.
• Объявление локальной WrappedInt-переменной по имени wi и инициализация ее новым WrappedInt-объектом путем вызова пассивного конструктора.
• Запись значения wi.Number в консоль.
• Вызов метода Pass.Reference с передачей wi в качестве аргумента.
• Повторная запись значения wi.Number в консоль.
Как и прежде, при вызове метода Console.WriteLine вы сможете увидеть, изменяет ли значение wi.Number вызов Pass.Reference. Теперь метод doWork должен приобрести следующий вид (новые инструкции выделены жирным шрифтом):
static void doWork()
{
// int i = 0;
// Console.WriteLine(i);
// Pass.Value(i);
// Console.WriteLine(i);
WrappedInt wi = new WrappedInt();
Console.WriteLine(wi.Number);
Pass.Reference(wi);
Console.WriteLine(wi.Number);
}
Щелкните в меню Отладка на пункте Запуск без отладки, чтобы выполнить сборку и запуск приложения. На этот раз два значения, отображаемые в окне консоли, соответствуют значению wi.Number до и после вызова метода Pass.Reference. Вы должны увидеть, что на экране отображаются значения 0 и 42.
Нажмите Ввод, чтобы закрыть приложение и вернуться в среду Visual Studio 2015.
Давайте разберемся с тем, что показывает предыдущее упражнение. Значением wi.Number при инициализации, осуществляемой создаваемым компилятором пассивным конструктором, становится нуль. В переменной wi содержится ссылка на только что созданный WrappedInt-объект, содержащий int-значение. Затем переменная wi копируется в качестве аргумента в метод Pass.Reference. Поскольку WrappedInt является классом (ссылочный тип), и wi и param ссылаются на один и тот же WrappedInt-объект. Любые изменения, вносимые в содержимое объекта посредством переменной param в методе Pass.Reference, видны вследствие использования переменной wi, когда метод завершает работу. На следующей схеме показано, что происходит, когда WrappedInt-объект передается методу Pass.Reference в качестве аргумента (рис. 8.2).
Рис. 8.2
Когда объявляется переменная, ее лучше всего сразу же инициализировать. При использовании типов значений наиболее часто встречается следующий код:
int i = 0;
double d = 0.0;
Вспомним, что для инициализации такой ссылочной переменной, как класс, можно создать новый экземпляр класса и присвоить ссылочной переменной новый объект:
Circle c = new Circle(42);
Все вроде бы хорошо, но что делать, если вам не нужно создавать новый объект? Возможно, переменная предназначается для простого сохранения ссылки на существующий объект в каком-нибудь другом месте программы. В следующем примере кода инициализируется копия Circle-переменной, но затем ей присваивается ссылка на другой экземпляр класса Circle:
Circle c = new Circle(42);
Circle copy = new Circle(99); // произвольное значение для инициализации копии
...
copy = c; // переменные copy и c ссылаются на один и тот же
объект
А что же случилось с исходным Circle-объектом со значением поля radius, равным 99, которое использовалось для инициализации copy после того, как переменной copy была присвоена переменная c? Теперь на него больше ничего не ссылается. В такой ситуации среда выполнения может освободить используемую им память, выполнив операцию, известную как сборка мусора, которая более подробно будет рассмотрена в главе 14 «Использование сборщика мусора и управление ресурсами». А пока нам важно уяснить, что сборка мусора является потенциально затратной по времени операцией, и во избежание пустой траты времени и ресурсов вы не должны создавать объекты, которые никогда не будут использоваться.
Можно согласиться с тем, что если переменной в каком-то месте программы собираются присвоить ссылку на другой объект, то нет никакого смысла ее инициализировать. Но применять подобные приемы программирования не стоит, поскольку это может привести к проблемам в коде. Например, вы неизбежно окажетесь в такой ситуации, когда нужно будет, чтобы переменная ссылалась на объект только в том случае, если, как показано в следующем примере кода, эта переменная еще не содержит ссылку:
Circle c = new Circle(42);
Circle copy; // Неинициализированная переменная !!!
...
if (copy == // выполнить для переменной copy операцию присваивания только в том
// случае, если эта переменная не инициализирована, но что тогда
// должно быть на этом месте?
{
copy = c; // переменные copy и c ссылаются на один и тот же объект
...
}
Инструкция if предназначена для проверки переменной copy на предмет того, инициализирована она или нет, но с каким значением следует сравнивать эту переменную? Ответ будет следующим: со специальным значением, которое называется пустым (null-значением).
В C# присваивать пустое значение null можно любой ссылочной переменной. Значение null просто означает, что переменная не ссылается на объект в памяти. Его можно использовать следующим образом:
Circle c = new Circle(42);
Circle copy = null; // Инициализирована
...
if (copy == null)
{
copy = c; // переменные copy и c ссылаются на один и тот же объект
...
}
Операторы проверки на Null-значение
В самую последнюю версию C# включен новый оператор проверки на null-значение, позволяющий использовать более краткую запись. Чтобы воспользоваться этим оператором, к имени переменной следует добавить вопросительный знак (?).
Предположим, к примеру, что вы пытаетесь вызвать метод Area в отношении Circle-объекта, когда этот объект имеет значение null:
Circle c = null;
Console.WriteLine($"The area of circle c is {c.Area()}");
В этом случае метод Circle.Area выдаст исключение NullReferenceException, что вполне резонно, поскольку вычислить площадь несуществующего круга нельзя.
Чтобы избежать такого исключения, перед попыткой вызова метода Circle.Area можно протестировать наличие у Circle-объекта значения null:
if (c != null)
{
Console.WriteLine($"The area of circle c is {c.Area()}");
}
В данном случае, если переменная c имеет значение null, в окно командной строки ничего выводиться не будет. Перед попыткой вызова метода Circle.Area в отношении Circle-объекта можно также воспользоваться оператором проверки на null-значение:
Console.WriteLine($"The area of circle c is {c?.Area()}");
Этот оператор заставляет среду выполнения проигнорировать текущую инструкцию, если переменная, к которой применяется оператор, имеет значение null. В данном случае в окне командной строки будет выведен следующий текст:
The area of circle c is
Применимы оба этих подхода, и они могут отвечать вашим потребностям при различных сценариях. Оператор проверки на null-значение может помочь сохранить краткость кода при работе со сложными свойствами, имеющими вложенные ссылочные типы, у которых могут быть null-значения.
Пустое значение хорошо подходит для инициализации ссылочных типов. Для типов значений требуется эквивалентное значение, а значение null само по себе является ссылкой, поэтому присваивать его типу значения нельзя. Из-за этого недопустимо применять в C# следующую инструкцию:
int i = null; // недопустимо
И тем не менее в C# определен модификатор, которым можно воспользоваться для объявления принадлежности переменной к типу значений, допускающих пустое значение. Тип значений, допускающий пустое значение, ведет себя точно так же, как и исходный тип значений, но переменной этого типа можно присвоить значение null. Для обозначения того, что тип значений допускает использование пустых значений, применяется вопросительный знак (?):
int? i = null; // допустимо
Выяснить, содержит ли переменная, относящаяся к этому типу, значение null, можно путем тестирования этой переменной аналогично тестированию переменной ссылочного типа:
if (i == null)
...
Вы можете присвоить выражение соответствующего типа значений переменной, допускающей пустое значение. Все следующие примеры кода являются вполне допустимыми:
int? i = null;
int j = 99;
i = 100; // Копирование константы в переменную типа, допускающего пустые значения
i = j; // Копирование переменной в переменную типа, допускающего пустые значения
Следует заметить, что обратное утверждение будет неверно. Переменную, допускающую пустое значение, нельзя присвоить переменной обычного типа значений. То есть если взять определения переменных i и j из предыдущего примера, то следующая инструкция будет недопустимой:
j = i; // недопустимо
И это вполне резонно, если учесть, что переменная i может содержать null-значение, а переменная j относится к типу значений, которые не могут быть пустыми. Это также означает, что переменную, допускающую пустое значение, нельзя использовать в качестве параметра метода, ожидающего значение, относящееся к обычному типу значений. Если вспомнить, что метод Pass.Value из предыдущего упражнения ожидал обычный int-параметр, то следующий вызов метода откомпилирован не будет:
int? i = 99;
Pass.Value(i); // Ошибка в ходе компиляции
ПРИМЕЧАНИЕ Не следует путать обозначение типов значений, допускающих пустые значения, с оператором проверки на null-значение. Типы, допускающие пустые значения, обозначаются добавлением вопросительного знака к имени типа, а оператор проверки на null-значение добавляется к имени переменной.
Типы значений, допускающие пустые значения, предоставляют два свойства, которыми можно воспользоваться для определения наличия непустого значения и того, каким это значение является. Свойство HasValue показывает, содержит ли переменная, допускающая пустое значение, именно его, или у нее есть непустое значение. А непустое значение из переменной, относящейся к типу, допускающему пустые значения, можно извлечь путем считывания свойства Value:
int? i = null;
...
if (!i.HasValue)
{
// Если переменная i содержит пустое значение, присвоить ей значение 99
i = 99;
}
else
{
// Если она не содержит пустое значение, вывести ее значение на экран
Console.WriteLine(i.Value);
}
В главе 4 «Использование инструкций принятия решений» утверждалось, что оператор НЕ (!) инвертирует булево значение. В данном фрагменте кода производится тестирование переменной i, относящейся к типу значений, допускающему пустые значения, и если в ней нет значения (если она пустая), ей присваивается значение 99, в противном случае на экран выводится значение этой переменной. В этом примере использование свойства HasValue не дает каких-либо преимуществ по сравнению с непосредственным тестированием на пустое значение. Кроме того, считывание значения свойства Value — слишком многословный способ чтения содержимого переменной. Но эти очевидные недостатки обусловлены тем, что int? является очень простым типом, допускающим пустые значения. Преимущества использования свойств HasValue и Value проявятся, скорее всего, при создании более сложных типов значений, которые можно будет использовать для объявления переменных, допускающих пустые значения. Соответствующие примеры можно будет увидеть в главе 9 «Создание типов значений с использованием перечислений и структур».
ПРИМЕЧАНИЕ Свойство Value типа значений, допускающего пустые значения, предназначено только для чтения. Им можно воспользоваться для считывания значения переменной, но не для его изменения. Для изменения значения переменной, допускающей пустые значения, используется обычная инструкция присваивания.
Обычно, когда методу передается аргумент, его копией инициализируется соответствующий параметр. Это утверждение справедливо независимо от того, к какому типу значений относится параметр — обычному (например, int), или допускающему пустые значения (например, int?), или же ссылочному (например, WrappedInt). Это обстоятельство означает, что никакие изменения параметра не повлияют на значение переданного ему аргумента. Например, в следующем коде значением, выводимым на консоль, является 42, а не 43. Метод doIncrement выполняет операцию инкремента копии аргумента (arg), а не его оригинала:
static void doIncrement(int param)
{
param++;
}
static void Main()
{
int arg = 42;
doIncrement(arg);
Console.WriteLine(arg); // записывается 42, а не 43
}
В предыдущем упражнении было показано, что если параметр метода относится к ссылочному типу, то любые изменения с использованием этого параметра приводят к изменениям данных, на которые ссылался переданный методу аргумент. Ключевым моментом здесь является следующее: хотя данные, на которые делалась ссылка, изменились, аргумент, переданный в качестве параметра, изменений не претерпел — он по-прежнему ссылается на тот же самый объект. Иными словами, хотя через параметр можно изменить объект, на который ссылается аргумент, сам аргумент изменить нельзя (например, перенастроить его таким образом, чтобы он ссылался на совершенно другой объект). В большинстве случаев такая гарантия весьма полезна и способна помочь в уменьшении количества ошибок в программе. Но временами может понадобиться создать метод, которому действительно нужно изменить аргумент. Для этих целей в C# предоставляются ключевые слова ref и out.
Если перед параметром поставить ключевое слово ref, компилятор C# создаст код, передающий ссылку на сам аргумент, а не на его копию. При использовании ref-параметра все, что делается с параметром, делается и с исходным аргументом, поскольку и параметр и аргумент ссылаются на одни и те же данные. При передаче аргумента в качестве ref-параметра впереди него также нужно поставить ключевое слово ref. Этот синтаксис является для программиста полезным визуальным признаком того, что аргумент может измениться. Вот еще одна версия предыдущего примера, в которой на этот раз используется ключевое слово ref:
static void doIncrement(ref int param) // используется ref
{
param++;
}
static void Main()
{
int arg = 42;
doIncrement(ref arg); // используется ref
Console.WriteLine(arg); // записывается 43
}
Теперь метод doIncrement получает ссылку на оригинальный аргумент, а не на его копию, поэтому любые изменения, вносимые этим методом, при использовании данной ссылки фактически изменяют оригинальное значение. Потому-то на консоль и выводится значение 43.
Не забывайте, что в C# действует правило, согласно которому значение переменной должно быть присвоено до того, как вы сможете его прочитать. Это правило применяется и к аргументам метода: методу нельзя передать в качестве аргумента переменную, не прошедшую инициализацию, даже если аргумент определен в качестве ref-аргумента. Например, в следующем примере переменная arg не прошла инициализацию, поэтому код не пройдет компиляцию. Этот сбой произойдет по причине того, что инструкция param++; внутри метода doIncrement на самом деле является псевдонимом инструкции arg++;, а эта операция допустима, только если у arg имеется вполне определенное значение:
static void doIncrement(ref int param)
{
param++;
}
static void Main()
{
int arg; // переменная не инициализирована
doIncrement(ref arg);
Console.WriteLine(arg);
}
Компилятор перед вызовом метода проверяет, присвоено ли ref-параметру значение. Но может возникнуть ситуация, при которой инициализацию параметра нужно будет возложить на сам метод. Это можно сделать с использованием ключевого слова out.
Синтаксически это ключевое слово похоже на ref. Ключевое слово out можно поставить перед параметром, превратив его в псевдоним для аргумента. Как и при использовании ref, все, что делается с параметром, делается и с оригинальным аргументом. Когда аргумент передается out-параметру, перед этим аргументом также следует поставить out.
Ключевое слово out является сокращением от слова output (вывод). Когда методу передается out-параметр, то, как показано в следующем примере, метод должен присвоить ему значение до завершения своей работы или до возвращения управления коду, вызвавшему этот метод:
static void doInitialize(out int param)
{
param = 42; // Инициализация param до завершения работы
}
Следующий пример кода не проходит компиляцию, потому что doInitialize не присваивает param никакого значения:
static void doInitialize(out int param)
{
// Ничего не делается
}
Поскольку метод должен присвоить out-параметру какое-либо значение, этот метод может быть вызван без инициализации его аргумента. Например, в следующем коде doInitialize вызывается для инициализации переменной arg, значение которой затем выводится на консоль:
static void doInitialize(out int param)
{
param = 42;
}
static void Main()
{
int arg; // переменная не инициализирована
doInitialize(out arg); // вполне допустимо
Console.WriteLine(arg); // записывается 42
}
Работа ref-параметров будет изучена в следующем упражнении.
Вернитесь в среду Visual Studio 2015 к проекту Parameters. Выведите в окно редактора файл Pass.cs и отредактируйте метод Value, чтобы он принимал ref-параметр.
Метод Value должен выглядеть следующим образом:
class Pass
{
public static void Value(ref int param)
{
param = 42;
}
...
}
Выведите в окно редактора файл Program.cs. Уберите символы комментария из первых четырех инструкций. Обратите внимание на то, что в третьей инструкции метода doWork — Pass.Value(i); — выявляется ошибка. Дело в том, что теперь метод Value ожидает ref-параметр. Отредактируйте эту инструкцию, чтобы при вызове метода Pass.Value аргумент ему передавался в виде ref-параметра.
ПРИМЕЧАНИЕ Четыре инструкции, создающие и тестирующие объект WrappedInt, оставьте в неизменном виде.
Теперь метод doWork должен выглядеть так:
class Program
{
static void doWork()
{
int i = 0;
Console.WriteLine(i);
Pass.Value(ref i);
Console.WriteLine(i);
...
}
}
Щелкните в меню Отладка на пункте Запуск без отладки, чтобы выполнить сборку и запуск программы. На этот раз первыми двумя значениями, записанными в окно консоли, будут 0 и 42. Этот результат показывает, что вызов метода Pass.Value успешно изменил аргумент i.
Нажмите Ввод, чтобы закрыть приложение и вернуться в среду Visual Studio 2015.
ПРИМЕЧАНИЕ Модификаторы ref и out можно использовать и для параметров ссылочного типа. Эффект будет точно таким же — параметр превратится в псевдоним для аргумента.
Компьютеры используют память для хранения выполняемых программ и используемых ими данных. Чтобы понять, чем отличаются друг от друга типы значений и ссылочные типы, полезно будет разобраться с организацией данных в памяти.
Операционные системы и среда выполнения, которая используется в языке C#, зачастую делят память, применяемую для хранения данных, на две отдельные области, управляемые по-разному. Эти две области памяти традиционно называются стеком и динамической памятью (кучей). Они служат разным целям.
• При вызове метода память, требующаяся для хранения его параметров и локальных переменных, всегда берется из стека. Когда работа метода завершается (либо по причине возвращения из него, либо из-за выдачи исключения), память, занятая в стеке параметрами и локальными переменными, автоматически высвобождается и снова становится доступной при вызове другого метода. Параметры метода и локальные переменные в стеке имеют вполне определенный период существования: они появляются при запуске метода и исчезают, как только метод завершает свою работу.
ПРИМЕЧАНИЕ Фактически такой же период существования и у переменных, определяемых в любом блоке кода, находящемся внутри открывающей и закрывающей фигурных скобок. В следующем примере кода переменная i создается при запуске на выполнение тела цикла while и исчезает, когда цикл while завершается, и выполнение кода продолжается уже после закрывающей фигурной скобки:
while (...)
{
int i = …; // здесь i создается в стеке
...
}
// а здесь i исчезает из стека
• Когда с помощью ключевого слова new создается объект (экземпляр класса), память, необходимая для его создания, всегда берется из динамической области (кучи). Вы уже видели, что с помощью ссылочных переменных ссылаться на один и тот же объект можно из разных мест. Когда исчезает последняя ссылка на объект, память, используемая под этот объект, снова становится доступной (хотя, возможно, и не сразу). Более подробное описание процесса высвобождения динамической памяти дается в главе 14. Поэтому у объектов, созданных в динамической памяти, менее определенный период существования: объект создается с помощью ключевого слова new, а исчезает только после удаления на него последней ссылки.
ПРИМЕЧАНИЕ Все переменные типов значений создаются в стеке. Все переменные ссылочных типов (объекты) создаются в динамической памяти (хотя сами ссылки находятся в стеке). Типы, допускающие пустые значения, фактически являются ссылочными типами, и переменные этих типов создаются в динамической памяти.
Названия «стек» и «куча» произошли от способа управления памятью со стороны среды выполнения.
• Стековая память организована наподобие стопки коробок, стоящих друг на друге. При вызове метода каждый параметр помещается в коробку, которая ставится на вершину стопки. Каждой локальной переменной как бы назначается своя собственная коробка, помещаемая поверх тех коробок, которые уже находятся в стопке. Когда работа метода завершается, можно представить все дело так, что коробки удаляются из стопки.
• Динамическая память похожа на кучу коробок, разбросанных по всей комнате, а не стоящих друг на друге. У каждой коробки имеется надпись, указывающая на то, используется она или нет. При создании нового объекта среда выполнения ищет пустую коробку и выделяет ее объекту. Ссылка на объект сохраняется в локальной переменной в стеке. Среда выполнения отслеживает количество ссылок на каждую коробку. (Вспомним, что две переменные могут ссылаться на один и тот же объект.) Когда исчезает последняя ссылка, среда выполнения помечает коробку как неиспользуемую, в некий момент в будущем опустошает ее и открывает к ней доступ.
Давайте посмотрим, что произойдет при вызове метода по имени Method:
void Method(int param)
{
Circle c;
c = new Circle(param);
...
}
Предположим, что аргумент, переданный в param, имеет значение 42. При вызове метода из стека выделяется блок памяти, достаточный для хранения int-значения, который инициализируется значением 42. Как только выполнение кода перемещается в метод, из стека выделяется еще один блок памяти, достаточно большой для хранения ссылки (адреса памяти), но он остается без инициализации. Этот блок памяти предназначен для Circle-переменной по имени c. Затем из кучи выделяется еще один фрагмент памяти, размер которого достаточен для хранения объекта Circle. Это делается при использовании ключевого слова new. Для превращения этого ничем не заполненного участка динамической памяти в объект Circle вызывается Circle-конструктор. Ссылка на этот объект Circle сохраняется в переменной c. Данную ситуацию может проиллюстрировать рис. 8.3.
На данный момент нужно отметить два обстоятельства.
• Хотя объект сохранен в куче, ссылка на объект (переменная c) хранится в стеке.
• Динамическая память (куча) небезгранична. Если она будет исчерпана, оператор new выдаст исключение недостатка памяти — OutOfMemoryException и объект создан не будет.
Рис. 8.3
ПРИМЕЧАНИЕ Circle-конструктор также может выдать исключение. Если это произойдет, память, выделенная объекту Circle, будет возвращена в оборот и конструктор возвратит пустое значение (null).
Когда метод завершит свою работу, параметры и локальные переменные выйдут из области видимости. Память, полученная для c и param, автоматически высвободится для дальнейшего использования в стеке. Среда выполнения замечает, что на объект Circle больше нет ссылок, и в какой-то момент в будущем помечает выделенную ему память как предназначенную для возвращения в оборот в куче (см. главу 14).
Одним из наиболее важных ссылочных типов в среде .NET Framework является класс Object, относящийся к пространству имен System. Чтобы в полной мере оценить важность класса System.Object, нужно разобраться с таким понятием, как наследование, которое рассматривается в главе 12 «Работа с наследованием». А пока просто примите на веру, что все классы являются специализированными типами System.Object и что System.Object можно использовать для создания переменной, ссылающейся на любой ссылочный тип. Важность класса System.Object настолько высока, что в C# в качестве его псевдонима предоставляется ключевое слово object. В вашем коде можно использовать как object, так и System.Object — они имеют одинаковое значение.
СОВЕТ Использование ключевого слова object более предпочтительно, чем System.Object. Оно имеет более конкретный смысл и хорошо сочетается с другими ключевыми словами, являющимися синонимами для классов (например, со string для System.String и с рядом других ключевых слов, рассматриваемых в главе 9).
В следующем примере переменные c и o ссылаются на один и тот же объект Circle. Тот факт, что типом c является Circle, а типом o является object (псевдоним для System.Object), в действительности дает две разные точки зрения на один и тот же элемент в памяти:
Circle c;
c = new Circle(42);
object o;
o = c;
На следующей схеме (рис. 8.4) показано, как переменные c и o ссылаются на один и тот же элемент в динамической памяти (куче).
Рис. 8.4
Как вы только что видели, переменные типа object могут ссылаться на любой элемент любого ссылочного типа. Но переменные типа object могут ссылаться и на типы значений. Например, следующие две инструкции инициализируют переменную i (типа int, который относится к типам значений) значением 42, а затем инициализируют переменную o (типа object, который относится к ссылочным типам) значением i:
int i = 42;
object o = i;
Чтобы разобраться в том, что происходит на самом деле, вторая инструкция требует небольшого пояснения. Вспомним, что i относится к типу значений, в силу чего находится в стеке. Если ссылка внутри переменной o ссылается непосредственно на i, то она будет ссылаться на стек. Но все ссылки должны ссылаться на объекты в динамической памяти: создание ссылок на элементы в стеке может создать серьезную угрозу устойчивости среды выполнения и открыть потенциальную брешь в системе безопасности, следовательно, это недопустимо. Поэтому среда выполнения выделяет участок динамической памяти, копирует в него значение целочисленной переменной i, а затем делает object o ссылкой на эту копию. Такое автоматическое копирование элемента из стека в кучу называется упаковкой (boxing). Результат показан на следующей схеме (рис. 8.5).
Рис. 8.5
ВНИМАНИЕ Если изменить оригинальное значение переменной i, значение в динамической памяти, на которое делается ссылка посредством переменной o, не изменится. Точно так же, если изменить значение в динамической памяти, оригинальное значение переменной не изменится.
Поскольку переменная типа object может ссылаться на упакованную копию значения, то смысл этого состоит всего лишь в том, чтобы позволить вам получить это упакованное значение через переменную. Вполне ожидаемо иметь возможность обращаться к упакованному int-значению, на которое ссылается переменная o, используя обычную инструкцию присваивания:
int i = o;
Но при попытке использования такого синтаксиса в ходе компиляции будет выдана ошибка. Если вдуматься, то невозможность использования синтаксиса int i = o; поддается разумному объяснению. В конечном счете o может ссылаться на что угодно, а не только на int-значение. Посмотрим, что бы получилось в следующем коде, будь такая инструкция разрешена:
Circle c = new Circle();
int i = 42;
object o;
o = c; // o ссылается на circle-переменную
i = o; // так что же тогда сохраняется в i?
Чтобы получить значение из упакованной копии, нужно воспользоваться приведением типов (cast). Это операция, проверяющая перед фактическим созданием копии безопасность превращения элемента одного типа в элемент другого типа. При этом перед переменной типа object нужно поставить название типа в круглых скобках:
int i = 42;
object o = i; // упаковка
i = (int)o; // компиляция проходит успешно
При таком приведении типов проделывается довольно тонкая работа. Компилятор видит, что в приведении указан тип int. Затем он создает код, проверяющий, на что фактически ссылается o в процессе выполнения программы. Это может быть что угодно. То, что приведение типов предписывает o ссылаться на int, еще не означает, что эта переменная ссылается на значение именно этого типа. Если o действительно ссылается на упакованное int-значение и все совпадает, приведение выполняется успешно и созданный компилятором код извлекает значение из int-упаковки и копирует его в i. (В данном примере упакованное значение затем хранится в i.) Эта операция называется распаковкой. Все происходящее проиллюстрировано на следующей схеме (рис. 8.6).
Рис. 8.6
В то же время, если o не ссылается на упакованное int-значение, возникает несоответствие типов, не позволяющее успешно выполнить приведение типа. Созданный компилятором код в ходе этого выдает исключение неверного приведения — InvalidCastException. Пример неудачного приведения типа показан в следующем фрагмента кода:
Circle c = new Circle(42);
object o = c; // упаковка не происходит, поскольку Circle — ссылочная переменная
nt i = (int)o; // компиляция проходит, но во время выполнения кода выдается
// исключение
Происходящее проиллюстрировано на рис. 8.7.
Рис. 8.7
Упаковка и распаковка будут применяться в следующих упражнениях. Нужно иметь в виду, что упаковка и распаковка относятся к ресурсоемким операциям, поскольку требуют определенного объема проверок и нуждаются в выделении дополнительных объемов динамической памяти. Упаковка, конечно, находит применение, но ее необдуманное использование может существенно снизить производительность программы. Альтернатива упаковке будет показана в главе 17 «Введение в обобщения».
Используя приведение типов, можно указать, что, по вашему мнению, данные, на которые ссылается объект, относятся к определенному типу и ссылаться на объект с использованием данного типа вполне безопасно. Ключевым здесь является уточнение «по вашему мнению». Компилятор C# не станет проверять, так ли это на самом деле, а вот среда выполнения обязательно проверит. Если тип объекта в памяти не соответствует приведению типа, среда выполнения, как говорилось в предыдущем разделе, выдаст исключение InvalidCastException. Вы должны быть готовы перехватить это исключение и соответствующим образом его обработать.
Но перехват исключения и попытка исправления ситуации в случае, когда тип объекта не соответствует ожиданиям, являются весьма неуклюжим подходом. C# предоставляет два очень полезных оператора, is и as, помогающих выполнить приведение типов более элегантно.
Оператор is можно использовать для проверки факта принадлежности объекта к тому или иному ожидаемому вами типу:
WrappedInt wi = new WrappedInt();
...
object o = wi;
if (o is WrappedInt)
{
WrappedInt temp = (WrappedInt)o; // Операция безопасна; o имеет тип WrappedInt
...
}
Этот оператор использует два операнда: ссылку на объект, указываемую по левую сторону от него, и название типа, указываемое по правую сторону. Если тип объекта в динамической памяти, на который осуществляется ссылка, соответствует указанному, выражение вычисляется в true, а если нет — в false. Предыдущий код пытается осуществить приведение типа в отношении ссылки на объект, содержащейся в переменной o, только в том случае, если заранее станет известно, что эта операция пройдет успешно.
Оператор as выполняет такую же роль, что и is, но в несколько усеченном виде. Этот оператор используется следующим образом:
WrappedInt wi = new WrappedInt();
...
object o = wi;
WrappedInt temp = o as WrappedInt;
if (temp != null)
{
... // приведение типа прошло успешно
}
Подобно оператору is, оператор as использует операнды, представляющие собой объект и тип. Исполняющая среда пытается привести объект к указанному типу. Если приведение проходит успешно, возвращается его результат, в данном примере он присваивается WrappedInt-переменной по имени temp. Если выполнить приведение не удается, оператор вычисляется в null и переменной temp присваивается пустое значение.
Что касается операторов is и as, то можно привести их развернутое описание, что и будет сделано в главе 12.
указатели и небезопасный код
Эта врезка является просто информацией к размышлению и предназначена для разработчиков, знакомых с C или C++. Новички в программировании могут ее пропустить.
Если вам уже приходилось создавать программы на языках C и C++, то с темой ссылок на объекты вы, скорее всего, уже знакомы, поскольку в обоих языках имеется конструкция, предоставляющая такую же функциональную возможность. Речь идет об указателе.
Указатель представляет собой переменную, в которой содержится адрес элемента в памяти или ссылка на этот элемент (неважно где, в куче или в стеке). Для обозначения переменной в качестве указателя используется специальный синтаксис. Например, следующая инструкция объявляет переменную pi в качестве указателя на целочисленное значение:
int *pi;
Хотя переменная pi объявлена в качестве указателя, фактически до своей инициализации она ни на что не указывает. Например, чтобы использовать pi в качестве указателя на целочисленную переменную i, можно воспользоваться следующими инструкциями и оператором получения адреса, address-of, (&), который возвращает адрес переменной:
int *pi;
int i = 99;
...
pi = &i;
У вас есть возможность через указатель в переменной pi обратиться к значению переменной i и изменить его:
*pi = 100;
Этот код изменяет значение переменной i на 100, поскольку pi указывает на то же самое место в памяти, что и переменная i.
Одной из основных проблем, с которыми сталкиваются разработчики, изучающие C и C++, является понимание синтаксиса, используемого указателями. Оператор * имеет как минимум два значения (кроме того что он является оператором арифметического умножения), и зачастую возникает путаница при попытке определить, что именно нужно использовать, оператор & или оператор *. Еще одна проблема указателей состоит в том, что они легко могут указать на что-то недопустимое или можно вообще забыть о конкретном нацеливании указателя, а затем пытаться сослаться на данные, на которые он якобы указывает. В результате произойдет либо засорение памяти, либо программный сбой с выдачей ошибки, поскольку операционная система обнаружит попытку доступа к запрещенному адресу памяти. Кроме того, во многих существующих системах неправильное управление указателями становится причиной возникновения целого ряда изъянов безопасности: некоторые среды (но не Windows) не в состоянии принуждать к проверке, относящейся к ссылке указателя на память, принадлежащую другому процессу, открывая тем самым возможность несанкционированного доступа к конфиденциальным данным.
Во избежание всех этих проблем в C# и были введены ссылочные переменные. Если есть желание, то можно продолжать использование указателей и в C#, но код при этом следует помечать как небезопасный. Для того чтобы поставить на блок кода или на весь метод метку, указывающую на его небезопасность, следует воспользоваться ключевым словом unsafe:
public static void Main(string [] args)
{
int x = 99, y = 100;
unsafe
{
swap (&x, &y);
}
Console.WriteLine($"x is now {x}, y is now {y}");
}
public static unsafe void swap(int *a, int *b)
{
int temp;
temp = *a;
*a = *b;
*b = temp;
}
При компиляции программ, содержащих небезопасный код, следует при сборке проекта установить флажок Разрешить небезопасный код. Для этого нужно щелкнуть правой кнопкой мыши на проекте в окне обозревателя решений, а затем щелкнуть на пункте Свойства. В окне свойств нужно щелкнуть на вкладке Сборка, установить флажок Разрешить небезопасный код, а затем в меню Файл щелкнуть на пункте Сохранить все.
Небезопасный код имеет отношение также к управлению памятью. Объекты, созданные в небезопасном коде, называются неуправляемыми. Хотя ситуации, требующие получения доступа к памяти подобным образом, возникают нечасто, с некоторыми из них вам, возможно, придется столкнуться, особенно если вы создаете код, требующий выполнения некоторых низкоуровневых операций Windows.
Более подробно о последствиях использования кода, обращающегося к неуправляемой памяти, будет рассказано в главе 14.
В этой главе вы узнали о ряде важных отличий типов значений, содержащих свои значения непосредственно в стеке, от ссылочных типов, опосредованно ссылающихся на свои объекты в динамической памяти. Вы также научились с целью доступа к аргументам использовать в отношении параметров методов ключевые слова ref и out. Вы увидели, как присваивание значения (например, int-значения 42) переменной класса System.Object создает упакованную копию значения в динамической памяти, а затем заставляет переменную System.Object ссылаться на эту упакованную копию. Вы также увидели, как присваивание переменной типа значений (например, int-переменной) из переменной класса System.Object приводит к копированию (или распаковке) значения в классе System.Object в память, используемую int-переменной.
Если хотите продолжить работу и изучить следующую главу, оставьте открытой среду Visual Studio 2015 и переходите к главе 9.
Если хотите выйти из среды Visual Studio 2015, то в меню Файл щелкните на пункте Выход. Если увидите диалоговое окно с предложением сохранить изменения, щелкните на кнопке Да и сохраните проект.
Чтобы | Сделайте следующее |
Скопировать переменную типа значений | Просто создайте копию. Поскольку переменная относится к типу значений, у вас будут две копии одного и того же значения, например: int i = 42; int copyi = i; |
Скопировать переменную ссылочного типа | Просто создайте копию. Поскольку переменная относится к ссылочному типу, у вас будут две ссылки на один и тот же объект, например: Circle c = new Circle(42); Circle refc = c; |
Объявить переменную, способную содержать тип значений или пустое значение | Объявите переменную с использованием модификатора ? с указанием типа, например: int? i = null; |
Передать аргумент ref-параметру | Поставьте перед параметром ключевое слово ref. Оно превратит параметр в псевдоним для аргумента, а не в копию этого аргумента. Метод может изменить значение параметра, и это изменение будет применено к самому аргументу, а не к его локальной копии, например: static void Main() { int arg = 42; doWork(ref arg); Console.WriteLine(arg); } |
Передать аргумент out-параметру | Поставьте перед параметром ключевое слово out. Оно превратит параметр в псевдоним для аргумента, а не в копию этого аргумента. Метод должен присвоить значение параметру, и это значение станет значением самого аргумента, например: static void Main() { int arg; doWork(out arg); Console.WriteLine(arg); } |
Упаковать значение | Инициализируйте переменную типа object этим значением или присвойте ей это значение, например: object o = 42; |
Распаковать значение | Выполните операцию приведения типа для ссылки на объект, которая ссылается на упакованное значение, к типу значения переменной, например: int i = (int)o; |
Провести безопасное приведение типов | Воспользуйтесь оператором is для тестирования допустимости приведения типа, например: WrappedInt wi = new WrappedInt(); ... object o = wi; if (o is WrappedInt) { WrappedInt temp = (WrappedInt)o; ... } Или же воспользуйтесь оператором as для приведения типа и тестирования результата на null-значение, например: WrappedInt wi = new WrappedInt(); ... object o = wi; WrappedInt temp = o as WrappedInt; if (temp != null) ... |