Прочитав эту главу, вы научитесь:
• управлять системными ресурсами, используя сборщик мусора;
• создавать код, запускаемый при уничтожении объекта;
• высвобождать ресурс в известный момент времени независимо от выдачи исключений путем использования пары инструкций try-finally;
• высвобождать ресурс в известный момент времени независимо от выдачи исключений путем использования инструкции using;
• реализовывать интерфейс IDisposable для поддержки в классе возможности высвобождения ресурсов независимо от выдачи исключений.
В предыдущих главах вы научились создавать переменные и объекты и должны понимать, как при их создании выделяется память. (Напомним, что типы значений создаются в стеке, а ссылочным типам выделяется участок динамической памяти, которую еще называют кучей.) Память компьютеров не безгранична, поэтому как только нужда в переменной или объекте отпадет, они должны быть возвращены. Типы значений уничтожаются, и память, которую они занимали, возвращается, как только они выходят из области видимости. С этим все ясно. А как насчет ссылочных типов? Объект создается с помощью ключевого слова new, но как и когда он уничтожается? Ответу на этот вопрос и посвящена данная глава.
Сначала давайте разберемся с тем, что происходит при создании объекта.
Объект создается с помощью оператора new. В следующем примере создается новый экземпляр класса Square, рассмотренного в главе 13 «Создание интерфейсов и определение абстрактных классов».
int sizeOfSquare = 99;
Square mySquare = new Square(sizeOfSquare); // Square является ссылочным типом
С вашей точки зрения, операция new происходит в один этап, но на самом деле объект создается в два этапа.
1. Операция new выделяет участок обычной динамической памяти. Этот этап создания объекта вам не подконтролен.
2. Операция new превращает участок обычной памяти в объект, который нужно инициализировать. Этот этап можно контролировать с помощью конструктора.
ПРИМЕЧАНИЕ Программисты, работавшие на C++, должны обратить внимание на то, что в C# перегрузить операцию new, чтобы контролировать выделение памяти, невозможно.
После создания объекта доступ к его компонентам можно получить с помощью оператора «точка» (.). Например, в классе Square имеется метод по имени Draw, который можно вызвать следующим образом:
mySquare.Draw();
ПРИМЕЧАНИЕ Этот код основан на версии класса Square, являющегося наследником абстрактного класса DrawingShape и не имеющего явной реализации интерфейса IDraw. Разъяснения можно найти в главе 13.
Когда переменная mySquare исчезает из области видимости, активных ссылок на Square-объект не остается. Поэтому объект может быть уничтожен, а занимаемая им память возвращена в оборот. (Но как вы увидите чуть позже, это может произойти не сразу.) Уничтожение объекта происходит в два этапа, являющихся зеркальным отражением двух этапов его создания.
1. Общеязыковой среде выполнения (common language runtime (CLR)) необходимо навести порядок. Контролировать этот этап можно созданием деструктора.
2. CLR должна вернуть память, ранее принадлежавшую объекту, в кучу; память, в которой находился объект, должна быть высвобождена. Контролировать этот этап невозможно.
Процесс уничтожения объекта и возвращения памяти в кучу известен как сборка мусора.
ПРИМЕЧАНИЕ Программисты, работавшие на C++, должны обратить внимание на то, что в C# нет оператора delete. Уничтожением объекта управляет CLR.
Деструктор может использоваться для любой уборки, требующейся, когда объект попадает под сборку мусора. Среда CLR автоматически высвободит любые управляемые ресурсы, используемые объектом, поэтому во многих подобных случаях в написании деструктора нет необходимости. Но если управляемый ресурс велик по объему (например, занят многомерным массивом), возможно, имеет смысл сделать его доступным для немедленного высвобождения путем установки для всех ссылок на этот ресурс, имеющихся в объекте, null-значений. Кроме того, применение деструктора может стать вполне оправданным, если объект непосредственно или опосредованно ссылается на неуправляемый ресурс.
ПРИМЕЧАНИЕ Опосредованные неуправляемые ресурсы встречаются довольно часто. К ним можно отнести файловые потоки, сетевые подключения, подключения к базам данных и другие ресурсы, управляемые Windows. Если, к примеру, в методе открывается файл, может понадобиться добавить деструктор, закрывающий файл при уничтожении объекта. Но, возможно, в зависимости от структуры кода в вашем классе лучше будет закрыть файл в какой-нибудь другой момент времени. (Обратите внимание на инструкцию using, рассматриваемую в этой главе чуть позже.)
Деструктор — это специальный метод, немного похожий на конструктор, но отличающийся от него тем, что он вызывается средой CLR при исчезновении объекта. В синтаксис для написания деструктора входит знак тильды (~), за которым следует имя класса. Например, далее показан простой класс, открывающий в своем конструкторе файл для чтения и закрывающий этот файл в своем деструкторе. (Учтите, что это просто пример, и я не рекомендую применять эту схему для открытия и закрытия файлов.)
class FileProcessor
{
FileStream file = null;
public FileProcessor(string fileName)
{
this.file = File.OpenRead(fileName); // open file for reading
}
~FileProcessor()
{
this.file.Close(); // close file
}
}
На деструкторы накладывается ряд весьма важных ограничений.
• Деструкторы применяются только к ссылочным типам — объявлять деструкторы применительно к типам значений, таким как структура, нельзя:
struct MyStruct
{
~MyStruct() { ... } // ошибка в ходе компиляции
}
• Для деструкторов нельзя указывать модификатор доступа, например public. Деструктор никогда не вызывается из вашего собственного кода, за вас это делает та часть среды CLR, которая называется сборщиком мусора:
public ~FileProcessor() { ... } // ошибка в ходе компиляции
• Деструктор не может принимать какие-либо параметры. Причина опять-таки в том, что вы никогда не вызываете деструктор самостоятельно:
~FileProcessor(int parameter) { ... } // ошибка в ходе компиляции
Компилятор C#, выполняя свою внутреннюю работу, автоматически транслирует деструктор в переопределение метода Object.Finalize. Компилятор преобразует этот деструктор
class FileProcessor
{
~FileProcessor() { // сюда помещается ваш код }
}
в следующий код:
class FileProcessor
{
protected override void Finalize()
{
try { // сюда помещается ваш код }
finally { base.Finalize(); }
}
}
Создаваемый компилятором метод Finalize содержит внутри try-блока тело деструктора, а после этого блока следует блок finally, который вызывает в базовом классе метод Finalize. (Ключевые слова try и finally рассматриваются в главе 6 «Обработка ошибок и исключений».) Тем самым гарантируется, что деструктор всегда вызывает деструктор своего базового класса, даже если исключение было выдано при выполнении вашего кода деструктора.
Важно усвоить, что это преобразование делает только компилятор. Вы не сможете написать собственный метод, переопределяющий метод Finalize, и не сможете сами вызвать метод Finalize.
Самостоятельно уничтожить объект, используя код C#, невозможно. Для этого просто не существует синтаксиса. Среда CLR делает это за вас по своему усмотрению. Кроме того, следует учесть, что для ссылки на один и тот же объект может быть создано более одной ссылочной переменной. В следующем примере кода на один и тот же объект FileProcessor указывают переменные myFp и referenceToMyFp:
FileProcessor myFp = new FileProcessor();
FileProcessor referenceToMyFp = myFp;
Сколько ссылок на объект можно создать? Да сколько угодно! Но такая неограниченность влияет на срок существования объекта. Среда CLR должна отслеживать все эти ссылки. Если переменная myFp исчезнет, выйдя из области видимости, по-прежнему могут существовать другие переменные (вроде referenceToMyFp) и ресурсы, используемые объектом FileProcessor, не могут быть высвобождены (файл не должен закрываться). Следовательно, срок существования объекта не может быть привязан к конкретной ссылочной переменной. Объект может быть уничтожен, но его память станет доступна для повторного использования, только когда на него исчезнут все ссылки.
Как видите, управлять сроком существования объекта нелегко, поэтому создатели C# решили снять с вашего кода эту ответственность. Если бы обязанность уничтожать объекты возлагалась на вас, то рано или поздно сложилась бы одна из следующих ситуаций.
• Вы бы забыли уничтожить объект. Следовательно, деструктор объекта (если таковой имелся) запущен не будет, уборка не состоится и память не будет возвращена в кучу. При этом память может быстро исчерпаться.
• Вы бы попытались уничтожить активный объект, что создало бы вероятность сохранения в одной или нескольких переменных ссылки на уничтоженный объект — так называемой висячей ссылки. Такая ссылка указывает либо на неиспользуемую область памяти, либо, возможно, на совершенно другой объект, который к этому моменту занял тот же участок памяти. Так или иначе, последствия использования висячей ссылки в лучшем случае приведут к неопределенности, а в худшем — к угрозам безопасности. Любой исход будет неприемлем.
• Вы бы попытались уничтожить один и тот же объект более одного раза. Это могло бы привести — а могло и не привести — к губительным последствиям в зависимости от кода, находящегося в деструкторе.
В таких языках, как C#, где надежность и безопасность ставятся на первые места в списке приоритетных проектных задач, подобные проблемы неприемлемы. Поэтому уничтожением объектов за вас занимается сборщик мусора. Этот сборщик гарантирует следующее.
• Каждый объект будет уничтожен, и его деструктор будет запущен. Когда программа завершит свою работу, все использовавшиеся в ней объекты будут уничтожены.
• Каждый объект будет уничтожен только единожды.
• Каждый объект будет уничтожен только тогда, когда станет недоступен, то есть когда в процессе выполнения вашего приложения на него не будет ссылок.
Пользу этих гарантий трудно переоценить, поскольку они освобождают программиста от утомительных забот, из-за которых легко ошибиться. Они позволяют вам сосредоточиться на самой логике программы и повысить продуктивность работы.
Когда же происходит сборка мусора? Этот вопрос может показаться странным. Ведь ясно, что это делается, как только отпадет надобность в объекте. Да, так и есть, но этот процесс не носит обязательный немедленный характер. Сборка мусора может быть весьма затратным делом, поэтому среда CLR занимается этим по мере надобности (когда объем доступной памяти снижается или, к примеру, размер кучи достигает определяемого системой порога), и тогда происходит максимально возможная сборка мусора. Эффективнее проводить несколько крупных операций по очистке памяти, чем множество мелких.
ПРИМЕЧАНИЕ Работу сборщика мусора можно инициировать в программе, вызвав статический метод Collect класса GC, который находится в пространстве имен System. Но за исключением редких случаев делать это не рекомендуется. Метод GC.Collect запускает сборщик мусора, но процесс выполняется в асинхронном режиме — метод GC.Collect перед возвращением управления не дожидается полного завершения работы сборщика, поэтому сведений о том, что ваши объекты уничтожены, вы не получаете. Позвольте выбрать наилучший момент для сборки мусора самой среде CLR.
Одна из особенностей сборщика мусора состоит в том, что вы не знаете порядка уничтожения объектов и не должны от него зависеть. Последняя особенность, которую, наверное, наиболее важно усвоить, заключается в следующем: деструкторы не запустятся до тех пор, пока объекты не попадут под сборку мусора. Если вами создается деструктор, следует понимать, что он будет выполнен, но вы не будете знать, когда именно это произойдет. Следовательно, не нужно создавать код, зависящий от запуска деструкторов в конкретной последовательности или к определенному времени выполнения вашего приложения.
Сборщик мусора запускается в своем собственном потоке и может выполняться только в определенные моменты времени, обычно когда ваше приложение завершит выполнение метода. В ходе его работы другие потоки, выполняющие ваше приложение, временно приостановятся, поскольку сборщику мусора может понадобиться переместить объекты и обновить на них ссылки, а он не сможет сделать это, пока объекты используются.
ПРИМЕЧАНИЕ Поток является отдельным путем выполнения приложения. Windows использует потоки, чтобы позволить приложению одновременно выполнять сразу несколько операций.
Сборщик мусора относится к сложным программным средствам, обладающим собственными настроечными возможностями и выполняющим ряд оптимизаций. Они должны сбалансировать потребности в сохранении доступной памяти с требованиями обеспечения высокой производительности приложения. Подробности внутренних алгоритмов и структур, используемых сборщиком мусора, выходят за рамки вопросов, рассматриваемых в данной книге (и компания Microsoft непрерывно совершенствует способы выполнения сборщиком мусора его работы), но если рассматривать вопрос на высоком уровне, шаги, предпринимаемые этим сборщиком, выглядят следующим образом.
1. Он составляет отображение всех доступных объектов, многократно следуя по полям ссылок внутри объектов. Это отображение создается сборщиком мусора весьма тщательно и гарантирует, что циклические ссылки не приведут к бесконечной рекурсии. Любой объект, отсутствующий в этом отображении, считается недоступным.
2. Он проверяет, имеется ли в каждом недоступном объекте деструктор, который необходимо выполнить (этот процесс называется финализацией (freachable)). Любой недоступный объект, требующий финализации, ставится в особую очередь, которая называется очередью объектов, готовых к финализации.
3. Он высвобождает память, ранее выделенную всем остальным недоступным объектам (не требующим финализации), путем перемещения доступных объектов в нижнюю часть кучи, дефрагментируя тем самым кучу и высвобождая память на ее вершине. Когда сборщик мусора перемещает доступный объект, он также обновляет все ссылки на него.
4. В этот момент он позволяет возобновить работу всем остальным потокам.
5. Он проводит финализацию недоступных объектов, требующих финализации (тех, что находятся в freachable-очереди), запуская в своем собственном потоке методы Finalize.
Создание классов, содержащих деструкторы, усложняет код и сборку мусора и вынуждает программу работать медленнее. Если в программе нет деструкторов, сборщику мусора не требуется помещать недоступные объекты в freachable-очередь и выполнять их финализацию. Очевидно, что всякая работа требует времени. Поэтому постарайтесь избегать использования деструкторов, за исключением тех случаев, когда в них есть реальная необходимость, используйте их только для возвращения неуправляемых ресурсов. (Чуть позже в этой главе будет рассмотрена альтернатива их использованию в виде инструкции using.)
При создании деструкторов нужно проявлять особую осмотрительность. В частности, имейте в виду, что при вызове деструктором других объектов эти другие объекты уже могут иметь собственные деструкторы, вызванные сборщиком мусора. Следует помнить, что при финализации какой-либо порядок выполнения деструкторов не гарантирован. Поэтому следует исключить взаимозависимость деструкторов или их наложение друг на друга. Не следует, к примеру, иметь два деструктора, пытающихся высвобождать одни и те же ресурсы.
Иногда высвобождать ресурс в деструкторе нецелесообразно, поскольку некоторые ресурсы слишком ценны, чтобы выжидать некий произвольный срок, пока сборщик мусора не займется их высвобождением. В высвобождении нуждаются такие дефицитные ресурсы, как память, подключения к базам данных или дескрипторы файлов, и делаться это должно как можно скорее. В подобных ситуациях единственным вариантом является самостоятельное высвобождение ресурса. Эту задачу можно выполнить, создав метод высвобождения ресурсов, который непосредственно этим и займется. Если в классе имеется метод высвобождения ресурсов, можно вызвать его и управлять моментом высвобождения.
ПРИМЕЧАНИЕ Понятие метода высвобождения ресурсов (disposal method) относится к назначению метода, а не к его имени. Этому методу можно дать имя, использующее любой допустимый в C# идентификатор.
Примером класса, реализующего метод высвобождения ресурсов, может послужить класс TextReader из пространства имен System.IO. Он предоставляет механизм для считывания символов из последовательного потока ввода. Класс TextReader содержит виртуальный метод по имени Close, который закрывает поток. Класс StreamReader, занимающийся считыванием символов из потока, например из открытого файла, и класс StringReader, считывающий символы из строки, являются производными класса TextReader, и в обоих этих классах происходит перегрузка метода Close. Рассмотрим пример, считывающий строки текста из файла с использованием класса StreamReader и выводящий их после этого на экран:
TextReader reader = new StreamReader(filename);
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
reader.Close();
Метод ReadLine считывает очередную строку текста из потока в строковую переменную. Если в потоке уже ничего не остается, метод ReadLine возвращает значение null. Когда считывание завершается, вызов Close имеет весьма большое значение для высвобождения дескриптора файла и ресурсов, связанных со считыванием строк. Но в примере есть одна проблема: он не может гарантированно работать при выдаче исключений. Если при вызове ReadLine или WriteLine выдается исключение, вызова Close не происходит — ему просто не будет передано управление. Если это случается довольно часто, ресурс дескрипторов файлов будет исчерпан и программа не сможет открывать дополнительные файлы.
Один из способов безусловного вызова метода высвобождения ресурсов, такого как Close, независимо от того, произошла или нет выдача исключения, заключается в вызове этого метода в блоке finally. Предыдущий пример, запрограммированный с применением этой технологии, имеет следующий вид:
TextReader reader = new StreamReader(filename);
try
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
finally
{
reader.Close();
}
Такое использование блока finally вполне работоспособно, но у него есть ряд недостатков, которые не позволяют признать это решение близким к идеалу.
• Если приходится высвобождать более одного ресурса, такое решение очень быстро становится громоздким. (В конечном итоге появляется большое количество вложенных блоков try и finally.)
• В некоторых случаях, чтобы вписаться в эту идиому, приходится изменять код. (Например, может понадобиться изменить порядок объявления ссылок на ресурсы, не забыть инициализировать ссылку null-значением и не забыть проверить в блоке finally, что ссылка не имеет null-значения.)
• Это решение не позволяет создать свою абстракцию. Это означает, что в решении трудно разобраться и приходится повторять код во всех местах, где требуется получение такой функциональной возможности.
• Ссылки на ресурс после блока finally остаются в области видимости. Это означает, что вы можете случайно предпринять попытку воспользоваться ресурсом уже после его высвобождения.
Для решения всех этих проблем и была разработана инструкция using.
Инструкция using предоставляет точный механизм управления продолжительностью использования ресурсов. Можно создать объект, который будет уничтожен сразу же, как только будет выполнен код блока using.
ВНИМАНИЕ Не нужно путать инструкцию using, показанную в этом разделе, с директивой using, которая вводит в область видимости пространство имен. Так уж, к сожалению, сложилось, что у одного и того же ключевого слова имеются два совершенно разных назначения.
Для инструкции using используется следующий синтаксис:
using ( type variable = initialization )
{
StatementBlock
}
А вот наилучший способ гарантировать безусловный вызов Close в TextReader:
using (TextReader reader = new StreamReader(filename))
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
Инструкция using является точным эквивалентом следующего преобразования:
{
TextReader reader = new StreamReader(filename);
try
{
string line;
while ((line = reader.ReadLine()) != null)
{
Console.WriteLine(line);
}
}
finally
{
if (reader != null)
{
((IDisposable)reader).Dispose();
}
}
}
ПРИМЕЧАНИЕ Собственный блок введен в инструкцию using с целью обозначения области видимости. Такое решение о структуре кода означает, что переменные, объявленные в инструкции using, автоматически выходят из области видимости в конце вложенной в блок инструкции и случайно получить доступ к освобожденному ресурсу будет уже невозможно.
Переменная, объявляемая в инструкции using, должна быть того типа, который реализует интерфейс IDisposable. Этот интерфейс находится в пространстве имен System и содержит только один метод по имени Dispose:
namespace System
{
interface IDisposable
{
void Dispose();
}
}
Метод Dispose предназначен для высвобождения всех используемых объектом ресурсов. Так уж вышло, что класс StreamReader реализует интерфейс IDisposable, а его метод Dispose вызывает метод Close, чтобы закрыть поток. Инструкция using может использоваться в качестве вполне понятного, не зависящего от выдачи исключений и надежного способа, гарантирующего безусловное высвобождение ресурса. Этот подход решает все проблемы, имеющиеся в создаваемом вручную решении, использующем пару инструкций try-finally. У вас появляется решение, способное:
• отлично масштабироваться, если требуется высвободить сразу несколько ресурсов;
• не искажать логику программного кода;
• абстрагироваться от проблем и избегать повторений;
• работать надежно. Оно исключает возможность случайной ссылки на переменную, объявленную внутри инструкции using (в данном случае имеется в виду переменная reader), после завершения выполнения инструкции using, поскольку она больше не находится в области видимости, и вы получите ошибку в ходе компиляции приложения.
Нужно ли при написании собственных классов включать в них деструктор или реализацию интерфейса IDisposable, чтобы инструкция using могла управлять экземплярами класса? Вызов деструктора состоится, но когда именно это произойдет, вам неизвестно. В то же время вы точно знаете, когда состоится вызов метода Dispose, но не можете быть уверены, случится ли это, поскольку все зависит от того, не забудет ли программист, использующий ваши классы, написать инструкцию using. Но есть возможность гарантировать безусловный запуск метода Dispose путем его запуска из деструктора. Получится полезный запасной вариант. Вы можете забыть вызвать метод Dispose, но по крайней мере будете уверены в том, что он будет вызван, даже если это произойдет в самом конце работы программы. Подробное изучение этой особенности будет предпринято в конце главы, а пока посмотрим на пример возможной реализации интерфейса IDisposable:
class Example : IDisposable
{
private Resource scarce; // дефицитный ресурс для управления и высвобождения
private bool disposed = false; // флажок, показывающий, был ли ресурс
// уже освобожден
...
~Example()
{
this.Dispose(false);
}
public virtual void Dispose()
{
this.Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (!this.disposed)
{
if (disposing)
{
// здесь происходит высвобождение объемного управляемого ресурса
...
}
// здесь происходит высвобождение неуправляемых ресурсов
...
this.disposed = true;
}
}
public void SomeBehavior() // метод класса Example
{
checkIfDisposed();
...
}
...
private void checkIfDisposed()
{
if (this.disposed)
{
throw new ObjectDisposedException("Example: object has been
disposed of");
}
}
}
Обратите внимание на следующие особенности класса Example.
• В классе реализуется интерфейс IDisposable.
• Открытый метод Dispose может быть вызван кодом вашего приложения в любое время.
• Открытый метод Dispose вызывает закрытую и перегружаемую версию метода Dispose, принимающую параметр в виде булева значения, передавая в качестве аргумента значение true. Этот метод фактически выполняет высвобождение ресурса.
• Деструктор вызывает закрытую и перегружаемую версию метода Dispose, принимающую параметр в виде булева значения, передавая в качестве аргумента значение false. Деструктор вызывается только сборщиком мусора при финализации объекта.
• Защищенный метод Dispose можно безопасно вызывать несколько раз. Переменная disposed показывает, запускался ли уже этот метод, и является надежным средством предотвращения попыток метода многократно высвобождать ресурсы при его одновременном вызове. (Ваше приложение может вызвать Dispose, но еще до того, как метод завершит работу, ваш объект может попасть под сборку мусора и метод Dispose будет запущен из деструктора средой CLR еще раз.) Ресурсы высвобождаются только при первом запуске метода.
• Защищенный метод Dispose поддерживает высвобождение управляемых ресурсов (например, большого массива) и неуправляемых ресурсов (например, дескриптора файла). Если параметр disposing имеет значение true, этот метод должен быть вызван из открытого метода Dispose. В этом случае высвобождаются все управляемые и неуправляемые ресурсы. Если параметр disposing имеет значение false, этот метод должен быть вызван из деструктора и финализацией объекта займется сборщик мусора. В этом случае нет необходимости в высвобождении управляемых ресурсов (или в независимости от выдачи исключений), поскольку они будут или уже были обработаны сборщиком мусора и высвобождаются только неуправляемые ресурсы.
• Открытый метод Dispose вызывает статический метод GC.SuppressFinalize. Этот метод не позволяет сборщику мусора вызывать в отношении данного объекта деструктор, поскольку объект уже был финализирован.
• Все обычные методы класса, например SomeBehavior, проверяют, не был ли объект уже освобожден, в случае чего выдают исключение.
В следующих упражнениях будет изучена возможность применения инструкции using для своевременного высвобождения ресурсов даже при выдаче исключений в коде вашего приложения. Сначала будет создан простой класс, реализующий деструктор и проверяющий, когда этот деструктор будет вызван сборщиком мусора.
ПРИМЕЧАНИЕ Класс Calculator, создаваемый в этих упражнениях, предназначен исключительно для иллюстрации основных принципов сборки мусора. Класс фактически не потребляет какие-либо существенные объемы управляемых или неуправляемых ресурсов. Для такого простого класса, как этот, вы вряд ли станете создавать деструктор или реализовывать интерфейс IDisposable.
Зайдите в среде Microsoft Visual Studio 2015 в меню Файл, выберите пункт Создать и щелкните на пункте Проект. Откроется диалоговое окно Создание проекта. Найдите в левой панели этого окна под пунктом Шаблоны пункт Visual C# и щелкните на нем. Выберите в средней панели шаблон Консольное приложение. Наберите в поле Имя, находящемся в нижней части окна, строку GarbageCollectionDemo. Укажите в поле Расположение папку Microsoft Press\VCSBS\Chapter 14 вашей папки документов и щелкните на кнопке OK.
СОВЕТ Вместо набора пути к папке вручную можно воспользоваться кнопкой Обзор, примыкающей к полю Расположение, и перейти к папке Microsoft Press\VCSBS\Chapter 14.
Visual Studio создаст новое консольное приложение и выведет файл Program.cs в окно редактора. Щелкните в меню Проект на пункте Добавить класс. Откроется диалоговое окно Добавить новый элемент — GarbageCollectionDemo. Убедитесь, что выбран шаблон Класс. Наберите в поле Имя строку Calculator.cs и щелкните на кнопке Добавить. Будет создан класс Calculator, и его код будет выведен в окно редактора.
Добавьте к классу Calculator следующий открытый метод Divide (выделенный жирным шрифтом):
class Calculator
{
public int Divide(int first, int second)
{
return first / second;
}
}
Это очень простой метод, который делит первый параметр на второй и возвращает результат. Он предоставляется исключительно для добавления функциональности, доступной для вызова приложением.
Добавьте выше метода Divide в самом начале класса Calculator открытый конструктор, выделенный в следующем примере кода жирным шрифтом:
class Calculator
{
public Calculator()
{
Console.WriteLine("Calculator being created");
}
...
}
Этот конструктор нужен для того, чтобы у вас была возможность проверить факт успешного создания Calculator-объекта.
После конструктора добавьте к классу Calculator деструктор, выделенный в следующем примере кода жирным шрифтом:
class Calculator
{
...
~Calculator()
{
Console.WriteLine("Calculator being finalized");
}
...
}
Этот деструктор всего лишь показывает сообщение, чтобы дать понять, что сборщик мусора запущен, и занимается финализацией экземпляра этого класса. При создании классов для настоящих приложений вы вряд ли станете заставлять деструктор выводить какой-либо текст.
Выведите файл Program.cs в окно редактора. Добавьте к методу Main класса Program инструкции, выделенные жирным шрифтом:
static void Main(string[] args)
{
Calculator calculator = new Calculator();
Console.WriteLine($"120 / 15 = {calculator.Divide(120, 15)}");
Console.WriteLine("Program finishing");
}
Этот код создает Calculator-объект, вызывает метод Divide этого объекта (и выводит результат), а затем выводит сообщение о том, что программа завершила работу.
Щелкните в меню Отладка на пункте Запуск без отладки. Убедитесь в том, что программа вывела на экран следующую серию сообщений:
Calculator being created
120 / 15 = 8
Program finishing
Calculator being finalized
Обратите внимание на то, что финализатор для Calculator-объекта запускается только тогда, когда приложение приближается к завершению своей работы, после того как будет выполнен метод Main.
Нажмите в окне консоли клавишу Ввод и вернитесь в среду Visual Studio 2015.
Среда CLR гарантирует, что под сборку мусора попадут все объекты, созданные вашими приложениями, но узнать, когда именно это произойдет, невозможно. В упражнении программа имела слишком короткий срок выполнения, и Calculator-объект был финализирован сразу же, как только по окончании выполнения программы среда CLR провела уборку. Но вы можете столкнуться и с тем, что такая же ситуация складывается и вокруг более объемных приложений, классы которых потребляют дефицитные ресурсы, и пока вы не предпримете необходимые меры по предоставлению средств высвобождения ресурсов, объекты, созданные вашим приложением, могут удерживать свои ресурсы вплоть до завершения его выполнения. Если ресурсом является файл, это может мешать другим пользователям получать доступ к этому файлу; если ресурсом является подключение к базе данных, то ваше приложение может не дать другим пользователям возможности подключиться к ней. В идеале хотелось бы высвободить ресурсы как можно скорее, то есть сразу же после того, как завершится их использование, не дожидаясь прекращения работы приложения.
В следующем упражнении в классе Calculator будет реализован интерфейс IDisposable, и программа сможет финализировать Calculator-объекты в выбранный ею момент времени.
Выведите в окно редактора файл Calculator.cs. Измените определение класса Calculator, чтобы в нем происходила реализация интерфейса IDisposable (изменение выделено жирным шрифтом):
class Calculator : IDisposable
{
...
}
Добавьте в конец класса Calculator метод по имени Dispose. Этот метод определяется интерфейсом IDisposable:
class Calculator : IDisposable
{
...
public void Dispose()
{
Console.WriteLine("Calculator being disposed");
}
}
Обычно к методу Dispose добавляется код, высвобождающий ресурсы, удерживаемые объектом. В данном случае такой код отсутствует, и назначение инструкции Console.WriteLine, имеющейся в этом методе, состоит в простом оповещении о том, что метод Dispose был запущен. В настоящих приложениях можно заметить некоторую повторяемость кода деструктора и метода Dispose. Чтобы избавиться от повторения, этот код обычно помещают в одно место, а вызывают из другого. Но поскольку вызвать явным образом деструктор из метода Dispose невозможно, вместо этого есть смысл вызвать метод Dispose из деструктора и поместить логику, высвобождающую ресурсы, в метод Dispose.
Внесите в деструктор следующее изменение, выделенное жирным шрифтом, чтобы он вызывал метод Dispose. (Оставьте инструкцию, выводящую на экран сообщение в финализаторе, на месте, чтобы можно было увидеть, когда он будет вызван сборщиком мусора.)
~Calculator()
{
Console.WriteLine("Calculator being finalized");
this.Dispose();
}
Когда в приложении потребуется уничтожить Calculator-объект, метод Dispose не будет запускаться автоматически — ваш код должен либо вызвать его явным образом (к примеру, с помощью инструкции calculator.Dispose()), либо создать Calculator-объект внутри инструкции using. В вашей программе будет использован последний подход.
Выведите в окно редактора файл Program.cs. Внесите в инструкции метода Main, создающие Calculator-объект и вызывающие метод Divide, изменения, выделенные жирным шрифтом:
static void Main(string[] args)
{
using (Calculator calculator = new Calculator())
{
Console.WriteLine($"120 / 15 = {calculator.Divide(120, 15)}");
}
Console.WriteLine("Program finishing");
}
Щелкните в меню Отладка на пункте Запуск без отладки. Убедитесь в том, что теперь программа выводит на экран следующую серию сообщений:
Calculator being created
120 / 15 = 8
Calculator being disposed
Program finishing
Calculator being finalized
Calculator being disposed
Инструкция using заставляет метод Dispose запускаться до инструкции, выводящей сообщение о завершении работы программы «Program finishing». Но можно заметить, что деструктор для Calculator-объекта по-прежнему запускается после завершения работы приложения и еще раз вызывает метод Dispose. Вполне очевидно, что он сработает впустую.
Нажмите в окне консоли клавишу Ввод и вернитесь в среду Visual Studio 2015.
Высвобождение удерживаемых объектом ресурсов более одного раза может пройти без негативных последствий, а может и обернуться крупными неприятностями, но вполне определенно такое развитие событий нельзя признать благоприятным. Рекомендуемый подход к решению данной проблемы заключается в добавлении к классу закрытого поля с булевым значением, показывающего, вызывался или нет метод Dispose, с последующей проверкой значения данного поля в этом методе.
Выведите в окно редактора файл Calculator.cs. Добавьте к классу Calculator закрытое булево поле по имени disposed и, как выделено жирным шрифтом, инициализируйте это поле значением false:
class Calculator : IDisposable
{
private bool disposed = false;
...
}
Это поле предназначено для отслеживания состояния данного объекта и хранения признака вызова метода Dispose.
Внесите в код метода Dispose изменение, при котором сообщение станет выводиться, только если значением поля disposed является false. После вывода сообщения установите для поля disposed значение true (все необходимые изменения выделены жирным шрифтом):
public void Dispose()
{
if (!this.disposed)
{
Console.WriteLine("Calculator being disposed");
}
this.disposed = true;
}
Щелкните в меню Отладка на пункте Запуск без отладки. Убедитесь в том, что программа выводит следующую серию сообщений:
Calculator being created
120 / 15 = 8
Calculator being disposed
Program finishing
Calculator being finalized
Теперь Calculator-объект уничтожается только один раз, но деструктор все равно работает. Опять же это делается впустую, поскольку нет никакого смысла запускать деструктор для объекта, который уже высвободил свои ресурсы.
Нажмите в окне консоли клавишу Ввод и вернитесь в среду Visual Studio 2015.
Добавьте к концу метода Dispose класса Calculator инструкцию, выделенную жирным шрифтом:
public void Dispose()
{
if (!this.disposed)
{
Console.WriteLine("Calculator being disposed");
}
this.disposed = true;
GC.SuppressFinalize(this);
}
Класс GC предоставляет доступ к сборщику мусора и реализует несколько статических методов, с помощью которых можно контролировать ряд выполняемых им действий. Используя метод SuppressFinalize, вы можете показать, что сборщику мусора не нужно выполнять финализацию указанного объекта, предотвратив тем самым запуск деструктора.
ВНИМАНИЕ Класс GC предоставляет целый ряд методов, с помощью которых можно настроить работу сборщика мусора. Но, как правило, лучше позволить заниматься сборкой мусора самой среде CLR, поскольку при неразумном самостоятельном вызове этих методов можно нанести серьезный ущерб производительности своего приложения. К методу SuppressFinalize следует относиться с особой осторожностью, поскольку в случае неудачного высвобождения ресурсов объекта вы рискуете потерять данные (к примеру, если корректно закрыть файл не удастся, то любые данные, находящиеся в буфере памяти, но еще не записанные на диск, могут быть утрачены). Вызывать этот метод следует только в ситуациях, подобных той, что сложилась в этом упражнении, когда вы знаете, что объект уже был уничтожен.
Щелкните в меню Отладка на пункте Запуск без отладки. Убедитесь в том, что программа выводит на экран следующую серию сообщений:
Calculator being created
120 / 15 = 8
Calculator being disposed
Program finishing
Как видите, деструктор больше не запускается, потому что Calculator-объект высвободил ресурсы еще до завершения работы программы.
Нажмите в окне консоли клавишу Ввод и вернитесь в среду Visual Studio 2015.
Безопасность потока и метод высвобождения ресурсов
Пример использования поля disposed для предотвращения многократного уничтожения объекта в большинстве случаев работает неплохо, но следует учесть, что вы не контролируете момент запуска финализатора. В упражнениях, представленных в данной главе, он всегда выполняется сразу же по окончании работы программы, но такое случается не всегда — он может быть запущен в любое время после исчезновения последней ссылки на объект. Поэтому вполне вероятно, что финализатор может быть вызван сборщиком мусора в его собственном потоке в то самое время, когда будет работать метод Dispose, особенно если этому методу приходится выполнять большой объем работы. Вероятность многократных попыток высвобождения ресурсов можно снизить перемещением поля disposed поближе к началу метода Dispose, но в таком случае вы рискуете вообще не высвободить ресурсы, если исключение будет выдано после установки значения для этой переменной, но до высвобождения ресурсов.
Чтобы полностью исключить возможность одновременного высвобождения одних и тех же ресурсов одного и того же объекта из двух параллельно выполняемых потоков, можно путем вставки в код C# инструкции lock написать код в таком стиле, который не зависит от потоковой организации выполнения кода:
public void Dispose()
{
lock(this)
{
if (!disposed)
{
Console.WriteLine("Calculator being disposed");
}
this.disposed = true;
GC.SuppressFinalize(this);
}
}
Инструкция lock предназначена для предотвращения запуска одного и того же блока кода одновременно в разных потоках. Аргументом инструкции lock (в данном примере это this) должна быть ссылка на объект. Код, заключенный в фигурные скобки, определяет область действия инструкции lock. Если выполнение программы доходит до инструкции lock, а указанный объект уже заблокирован, поток, требующий блокировки, блокируется и выполнение в этом месте программы приостанавливается. Когда поток, удерживающий в данный момент блокировку, дойдет в выполнении кода до закрывающей фигурной скобки инструкции lock, блокировка снимается, позволяя установить ее ранее заблокированному потоку и продолжить выполнение программы. Но к тому моменту, когда это случится, для поля disposed уже будет установлено значение true, поэтому второй поток не станет предпринимать попытку выполнения кода в блоке if (!disposed).
Такое использование блокировки безопасно, но может снизить производительность. Альтернативный подход заключается в использовании стратегии, описание которой давалось ранее, при этом предотвращается только повторное высвобождение управляемых ресурсов. (Высвобождение ресурсов, выполняемое более одного раза, не защищено от выдачи исключений: вы не поставите под угрозу безопасность вашего компьютера, но при попытке высвобождения ресурсов управляемого объекта, который больше не существует, может нарушиться логическая целостность вашего приложения.) В данной стратегии реализуются перегружаемые версии метода Dispose: инструкция using вызывает Dispose(), который в свою очередь запускает инструкцию Dispose(true), в то время как деструктор запускает инструкцию Dispose(false). Управляемые ресурсы высвобождаются, только если параметр перегруженной версии метода Dispose имеет значение true. Дополнительные сведения можно найти в разделе «Вызов метода Dispose из деструктора».
Инструкция using предназначена для выдачи гарантии о безусловном высвобождении ресурсов объекта, даже если в ходе его использования будет выдано исключение. В заключительном упражнении данной главы вам нужно будет убедиться, что именно так и происходит при выдаче исключения в ходе выполнения кода, находящегося в блоке инструкции using.
Выведите в окно редактора файл Program.cs. Внесите в инструкцию, вызывающую метод Divide класса Calculator, изменения, выделенные жирным шрифтом:
static void Main(string[] args)
{
using (Calculator calculator = new Calculator())
{
Console.WriteLine($"120 / 0 = {calculator.Divide(120, 0)}");
}
Console.WriteLine("Program finishing");
}
В измененной инструкции предпринимается попытка деления 120 на 0.
Щелкните в меню Отладка на пункте Запуск без отладки. Приложение вполне ожидаемо выдаст необработанное исключение DivideByZeroException.
Щелкните в окне сообщения GarbageCollectionDemo на кнопке Отмена (рис. 14.1). (Нужно успеть сделать это до появления кнопок Отладка и Закрыть программу.)
Рис. 14.1
Убедитесь в том, что в окне консоли сообщение «Calculator being disposed» появляется после необработанного исключения (рис. 14.2).
ПРИМЕЧАНИЕ Если вы промедлили и уже появились кнопки Отладка и Закрыть программу, щелкните на кнопке Закрыть программу и еще раз запустите приложение без отладки.
Нажмите в окне консоли клавишу Ввод и вернитесь в среду Visual Studio 2015.
Рис. 14.2
В этой главе была показана работа сборщика мусора и то, как среда .NET Framework использует его для высвобождения выделенных объектам ресурсов, очищая память. Вы научились создавать деструкторы, предназначенные для очистки используемых объектом ресурсов в ходе возвращения сборщиком мусора памяти для ее повторного использования. Вы также увидели, как используется инструкция using для реализации высвобождения ресурсов независимо от выдачи исключений и как реализуется интерфейс IDisposable для поддержки этой формы высвобождения ресурсов объекта.
Если хотите продолжить работу и изучить следующую главу, оставьте открытой среду Visual Studio 2015 и переходите к главе 15 «Реализация свойств для доступа к полям».
Если хотите выйти из среды Visual Studio 2015, то в меню Файл щелкните на пункте Выход. Если увидите диалоговое окно с предложением сохранить изменения, щелкните на кнопке Да и сохраните проект.
Чтобы | Сделайте следующее |
Создать деструктор | Напишите метод, имя которого совпадает с именем класса и имеет префикс в виде тильды (~). У этого метода не должно быть модификатора доступа (например, public), и у него не может быть никаких параметров и возвращаемого значения, например: |
| class Example { ~Example() { ... } } |
Вызвать деструктор | Вы не можете вызвать деструктор. Это может сделать только сборщик мусора |
Принудительно вызвать сборку мусора (не рекомендуется) | Вызовите GC.Collect |
Высвободить ресурс в известный момент времени (но с риском утечки ресурса в случае прерывания выполнения программы из-за выдачи исключения) | Напишите метод высвобождения ресурсов (который высвобождает ресурс) и вызовите его из программы явным образом, например: class TextReader { ... public virtual void Close() { ... } } class Example { void Use() { TextReader reader = ...; // использование переменной reader reader.Close(); } } |
Поддержать в классе высвобождение ресурсов независимо от выдачи исключений | Реализуйте интерфейс IDisposable, например: class SafeResource : IDisposable { ... public void Dispose() { // здесь высвобождаются ресурсы } } |
Выполнить независимое от выдачи исключений высвобождение ресурсов объекта, реализующего интерфейс IDisposable | Создайте объект в инструкции using, например: using (SafeResource resource = new SafeResource()) { // здесь используется SafeResource ... } |