Классы — сердце каждого объектно-ориентированного языка. Как вы помните, класс представляет собой инкапсуляцию данных и методов их обработки (см. главу 1). Это справедливо для любого объектно-ориентированного языка и отличаются они в этом плане лишь типами тех данных, которые можно хранить в виде членов, а также возможностями классов. В том, что касается классов и многих функций языка, С# кое-что заимствует из C++ и Java, и привносит немного изобретательности, помогающей найти элегантные решения старых проблем.
В этой главе я сначала опишу основы определения классов на С#, включая члены-экземпляров, модификаторы доступа, конструкторы и инициализационные списки, затем перейду к определению статических членов и раскрою разницу между постоянными и неизменяемыми полями. Потом я расскажу о деструкторах и о детерминированном завершении. В конце главы мы вкратце обсудим наследование и классы С#.
Синтаксис определения классов на С#, прост, особенно если вы программируете на C++ или Java. Поместив перед именем вашего класса ключевое слово class, вы вставляете члены класса, заключенные в фигурные скобки, например:
class Employee {
private long employeeld; }
Как видите, этот простейший класс с именем Employee содержит единственный член — employeeld. Заметьте: имени члена предшествует ключевое слово private — это модификатор доступа (access modifier). В
С# определено четыре модификатора доступа, и совсем скоро я расскажу о них.
В главе 4 я рассказал о типах, определенных в CIS (Common Type System). Эти типы поддерживаются как члены классов С# и бывают следующих видов.
Теперь, зная, что типы могут быть определены как члены класса С#, познакомимся с модификаторами, используемыми для задания степени "видимости", или доступности данного члена для кода, лежащего за пределами его собственного класса. Они называются модификаторами доступа (access modifiers) (табл. 5-1).
Табл. 5-1. Модификаторы доступа в С#.
Модификатор доступа | Описание |
public | Член доступен вне определения класса и иерархии производных классов. |
protected | Член невидим за пределами класса, к нему могут обращаться только производные классы. |
private | Член недоступен за пределами области видимости определяющего его класса. Поэтому доступа к этим членам нет даже у производных классов. |
internal | Член видим только в пределах текущей единицы компиляции. Модификатор доступа internals плане ограничения доступа является гибридом public и protected, зависимым от местоположения кода. |
Если вы не хотите оставить модификатор доступа для данного члена по умолчанию (private), задайте для него явно модификатор доступа. Этим С# отличается от C++, где член, для которого явно не указан модификатор доступа, принимает на себя характеристики видимости, определяемые модификатором доступа, заданным для предыдущего члена. Например, в приведенном ниже коде на C++ видимость членов а, Ъ и с определена модификатором public, а члены dvL e определены как protected:
class CAccessModsInCpp {
public:
int a;
int b;
int c;
protected: int d; int e; >
Чтобы решить аналогичную задачу на С#, этот код нужно изменить:
class AccessModsInCSharp {
public Int a;
public int b;
public int c;
protected int d;
protected int e; >
В результате выполнения следующего кода на С# член Ь объявляется как private:
public MoreAccessModsInCSharp
<
public int a; int b; }
У каждого приложения на С# должен быть метод Main, определенный в одном из его классов. Кроме того, этот метод должен быть определен как public и static (ниже я объясню, что значит static). Для компилятора С# ле важно, в каком из классов определен метод Main, а класс, выбранный для этого, не влияет на порядок компиляции. Здесь есть отличие от C++, так как там зависимости должны тщательно отслеживаться при сборке приложения. Компилятор С# достаточно "умен", чтобы самостоятельно просмотреть ваши файлы исходного кода и отыскать метод Main. Между тем этот очень важный метод является точкой входа во все приложения на С#.
Вы можете поместить метод Main в любой класс, но я для его размещения рекомендовал бы создавать специальный класс. Это можно сделать, используя наш простой (пока еще) класс Employee:
class Employee {
private int employeeld; }
class AppClass {
static public void MainQ {
Employee emp = new EmployeeQ; } }
Как видите, здесь два класса. Этот общий подход используется при программировании на С# даже простейших приложений. Employee представляет собой класс предметной области, a AppClass содержит точку входа приложения (Main). В этом случае метод Main создает экземпляр объекта Employee, и, если бы это было настоящее приложение, оно бы использовало члены объекта Employee.
Вы можете обращаться к аргументам командной строки приложения, объявив метод Main как принимающий аргументы типа массива строк. Затем аргументы могут обрабатываться так же, как любой массив. Хотя речь о массивах пойдет только в главе 7, ниже приводится простой код, который по очереди выводит все аргументы командной строки на стандартное устройство вывода.
using System;
class CommandLineApp
{
public static void Main(string[] args) {
foreach (string arg in args) {
Console.WriteLine("Аргумент: {О}", arg); } } }
А вот пример вызова этого приложения с парой случайно выбранных чисел:
e:>CommandlineApp 5 42 Аргумент: 5 Аргумент: 42
Аргументы командной строки передаются в виде массива строк. Если это флаги или переключатели, их обработку вы должны запрограммировать сами.
ПРИМЕЧАНИЕ Разработчики на Microsoft Visual C++ уже приучены к циклической обработке массива, представляющего аргументы командной строки. Но в отличие от C++ в С# массив аргументов командной строки не содержит имени приложения в качестве первого элемента массива.
Большинство примеров в этой книге определяют метод Main так:
class SomeClass {
public static void Main() {
}
}
Однако вы можете определить метод Main так, чтобы он возвращал значения типа int. Хотя это не является общепринятым в приложениях с графическим интерфейсом, такой подход может быть полезным в консольных приложений, предназначенных для пакетного исполнения. Оператор return завершает исполнение метода, а возвращаемое при этом значение применяется вызывающим приложением или пакетным файлом как код ошибки для вывода определенного пользователем сообщения об успехе или неудаче. Для этого служит следующий прототип:
public static int Main() {
// Вернуть некоторое значение типа int, // представляющее код завершения, return 0; }
В С# разработчиками включен механизм, позволяющий определять более одного
класса с методом Main. Зачем это нужно? Одна из причин — необходимость
поместить в ваши классы тестовый код. Затем, используя переключатель /юа/п:<имя_Класса>
компилятора С#, можно задавать класс, метод Main которого должен быть
задействован. Вот пример, в котором я создал два класса, содержащих методы Main'.
using System;
class Mainl {
public static void Main() {
Console.WriteLine("Main1"); } }
class Main2
{
public static void MainQ
{
Console.WriteLine("Main2");
} }
Чтобы скомпилировать это приложение так, что в качестве точки входа в приложение применялся бы метод Mainl.Main, нужно использовать этот переключатель:
esc MultipleMain.es /main:Mainl
При изменении переключателя на /main:Main2 будет использован метод Main2.Main.
Следует соблюдать осторожность и задавать в указанном в переключателе имени класса верный регистр символов, так как С# чувствителен к регистру. Кроме того, попытка компиляции приложения, состоящего из нескольких классов с определенными методами Main, без указания переключателя /main вызывает ошибку компилятора.
Одним из величайших преимуществ языков ООП, таких как С#, в том, что вы можете определять специальные методы, вызываемые всякий раз при создании экземпляра класса. Эти методы называются конструкторами (constructors). C# вводит в употребление новый тип конструкторов — статические (static constructors). С ними вы познакомитесь в разделе "Статические члены и члены экземпляров".
Гарантия инициализации объекта должным образом, прежде чем он будет использован, — ключевая выгода от конструктора. Когда пользователь создает экземпляр объекта, вызывается его конструктор, который должен вернуть управление, прежде чем пользователь сможет выполнить над объектом другое действие. Именно это помогает обеспечивать целостность объекта и сделать написание приложений на объектно-ориентированных языках гораздо надежнее.
Но как назвать конструктор, чтобы компилятор знал, что его надо вызывать при создании экземпляра объекта? Разработчики С# последовали в этом вопросе Страуструпу и провозгласили, что у конструкторов в С# должно быть то же имя, что и у самого класса. Вот простой класс с таким же простым конструктором:
using System;
class ConstructorlApp {
Constructor1App()
{
Console.WriteLine("Я конструктор.");
}
public static void Main() {
ConstructorlApp app = new Constructor1App(); } >
Значений конструкторы не возвращают. При попытке использовать с конструктором в качестве префикса имя типа, компилятор сообщит об ошибке, пояснив, что вы не можете определять члены с теми же именами, что у включающего их типа.
Следует обратить внимание и на способ создания экземпляров объектов в С#. Это делается при помощи ключевого слова new:
<класс> <объект> = new <класс> (аргументы конструктора)
Если раньше вы программировали на C++, обратите на это особое внимание. В C++ вы могли создавать экземпляр объекта двумя способами: объявлять его в стеке, скажем, так: /
// Код на C++. Создает экземпляр CmyClassj в стеке. CMyClass myClass;
или создать копию объекта в свободной памяти (или в куче), используя ключевое слово C++ new: \
/I Код на C++. Создает экземпляр CmyClass в куче. CMyClass myClass = new CMyClassQ;
Экземпляры объектов на С# создаются иначе, что и сбивает с толку новичков в разработке на С#. Причина путаницы в том, что для создания объектов оба языка используют одни и те же ключевые слова. Хотя с помощью ключевого слова new в C++ можно указать, где именно будет создаваться объект, место создания объекта на С# зависит от типа объекта, экземпляр которого создается. Как вы уже знаете, ссылочные типы создаются в куче, а размерные — в стеке (см. главу 4). Поэтому ключевое слово new позволяет создавать новые экземпляры класса, но не определяет место создания объекта.
Хотя можно сказать, что приведенный ниже код на С# не содержит ошибок, он делает совсем не то, что может подумать разработчик на C++:
MyClass myClass;
На C++ он создаст в стеке экземпляр MyClass. Как сказано выше, на С# вы можете создавать объекты, только используя ключевое слово new. Поэтому на С# эта строка лишь объявляет переменную myClass как переменную типа MyClass, но не создает экземпляр объекта.
Примером служит следующая программа, при компиляции которой компилятор С# предупредит, что объявленная в приложении переменная ни разу не используется:
using System;
class Constructor2App {
Constructor2App()
{
Console.WriteLine("Я конструктор");
}
public static void MainQ {
Constructor2App app; > }
Поэтому, объявляя объект, создайте где-нибудь в программе его экземпляр, используя ключевое слово new:
Constructог2Арр арр;
app =new Constructor2App();
Зачем объявлять объект, не создавая его экземпляров? Объекты объявляются перед использованием или созданием их экземпляров с помощью / new, если вы объявляете один класс внутри другого. Такая вложенность классов называется включение (containment) или агрегирование (aggregation).
Статические члены и члены экземпляров
Как и в C++, вы можете определить член класса как статический (static member) или член экземпляра (instance member). По умолчанию каждый член определен как член экземпляра. Это значит, что для каждого экземпляра класса делается своя копия этого члена. Когда член объявлен как статический, имеется лишь одна его копия. Статический член создается при загрузке содержащего класс приложения и существует в течение жизни приложения. Поэтому вы можете обращаться к члену, даже если экземпляр класса еще не создан. Хотя зачем вам это?
Один из примеров — метод Main. CLR (Common Language Runtime) нужна универсальная точка входа в ваше приложение. Поскольку CLR не должна создавать экземпляры ваших объектов, существует правило, требующее определить в одном из ваших классов статический метод Main. Вы можете захотеть использовать статические члены при наличии метода, который формально принадлежит классу, но не требует реального объекта. Скажем, если вам нужно отслеживать число экземпляров данного объекта, которое создается во время жизни приложения. Поскольку статические члены "живут" на протяжении жизни всех экземпляров объекта, должен работать такой код:
using System;
class InstCount <
public InstCountQ
{
instanceCount++; }
static public int instanceCount = 0; }
class AppClass <
public static void Main() /
< /
Console.WriteLine(InstCount.instanceCount);
InstCount id = new InstCountQ;
Console.WriteLine(InstCount.instanceCount);
InstCount ic2 = new InstCountO; Console.WriteLine(InstCount.instanceCount); }
}
В этом примере выходная информация будет следующая:
О
1
2
И последнее замечание по статическим членам: у статических членов должно быть некоторое допустимое значение. Его можно задать при определении члена:
static public int instanceCountl = 10;
Если вы не инициализируете переменную, это сделает CLR после запуска приложения, установив значение по умолчанию, равное 0. Поэтому следующие строки эквивалентны:
static public int instanceCount2; static public int instanceCount2 =0;
Во всех конструкторах С#, кроме System.Object, конструкторы базового класса вызываются прямо перед исполнением первой строки конструктора. Эти инициализаторы конструкторов позволяют задавать класс и подлежащий вызову конструктор. Они бывают двух видов.
Чтобы увидеть порядок событий в действии, обратите внимание на следующий код: он сначала исполнит конструктор класса А, а затем конструктор класса В:
using System;
class A
<
public A() {
Console.WriteLine("A"); } }
class В : А
<
public B() {
Console.WriteLine("B"); } }
class DefaultlnitializerApp {
public static void MainQ
{
В b = new B();
} }
Этот код — функциональный эквивалент следующего, где производится явный вызов конструктора базового класса:
using System;
class A {
public A()
{
Console.WriteLine("A");
} }
class В : A /
{
public BO : baseO
{
Console.WriteLine("B");
} }
class BaseDefaultlnitializerApp <
public static void Main()
{
В b = new BO;
} }
А теперь рассмотрим более удачный пример ситуации, когда выгодно использовать инициализаторы конструкторов. У меня опять два класса: А и В. На этот раз у класса А два конструктора, один из них не требует аргументов, а другой принимает аргумент типа int. У класса В один конструктор, принимающий аргумент типа int. При создании класса В возникает проблема. Если запустить следующий код, будет вызван конструктор класса А, не принимающий аргументов:
using System;
class A <
public АО {
Console.WriteLine("A"); >
public A(int foo) {
Console.WriteLineC'A = {0}", foo); } }
class В : А {
public B(int foo) {
Console.WriteLineC'B = {0}", foo);
} }
class DerivedlnitializeMApp {
public static void MainQ
{
В Ь = new B(42);
} }
Как же гарантировать, что будет вызван именно нужный конструктор класса А1 Явно указав компилятору, какой конструктор в инициализаторе должен быть вызван первым, скажем, так:
using System;
class A {
public A()
{
Console. Writel_ine( "A");
}
public A(int foo) {
Console.WriteLineC'A = {0}", foo); } >
class В : А {
public B(int foo) ; base(foo)
{
Console.WriteLine("B = {0}". foo);
} >
class DerivedInitializer2App {
public static void Main()
< / В b = new B(42); /
} /
} /
ПРИМЕЧАНИЕ В отличие от Visual C++ для
обращения к членам экземпляров вы не можете использовать инициализаторы конструкторов,
кроме конструкторов текущего класса.
Можно с уверенностью сказать, что возникнут ситуации, когда изменение некоторых полей при выполнении приложения будет нежелательно. Например, это могут быть файлы данных, от которых зависит ваше приложение, значение pi для математического класса или любое другое используемое в приложении значение, о котором вы знаете, что оно никогда не изменится. В этих ситуациях С# позволяет определять члены двух тесно связанных типов: константы и неизменяемые поля.
Из названия легко догадаться, что константы (constants), представленные ключевым словом const, — это поля, остающиеся постоянными в течение всего времени жизни приложения. Определяя что-либо как const, достаточно помнить два правила. Во-первых, константа — это член, значение которого устанавливается в период компиляции программистом или компилятором (в последнем случае это значение по умолчанию). Во-вторых, значение члена-константы должно быть записано в виде литерала.
Чтобы определить поле как константу, укажите перед определяемым членом ключевое слово const:
using System;
class MagicNumbers {
public const double pi = 3.1415; public const int answerToAllLifesQuestions = 42; >
Glass ConstApp {
public static void MainQ {
Console.WriteLine("pi = {0}, все остальное = Ш",
MagicNumbers.pi, MagicNumbers.answerToAllLifesQuestions); } }
Обратите внимание на один важный момент, связанный с этим кодом. Клиенту нет нужды создавать экземпляр класса MagicNumbers, поскольку по умолчанию члены const являются статическими. Чтобы получить более четкое представление о предмете, взгляните на MSIL-код, сгенерированный для этих двух членов:
answerToAHLifesQuestions : public static literal int32
=int32(Ox0000002A) pi :public static literal float64 =float64(3.1415000000000002)
Поле, определенное как const, ясно указывает, что программист намерен поместить в него постоянное значение. Это плюс. Но оно работает, только если известно значение подобного поля в период компиляции. А что • же делать программисту, когда возникает потребность в поле, чье значение не известно до периода выполнения, но после инициализации не должно меняться? Эта проблема (которая обычно остается нерешенной в большинстве других языков) разрешена разработчиками языка С# с помощью неизменяемого поля (read-only field).
Определяя поле с помощью ключевого поля readonly, вы можете установить значение поля лишь в одном месте — в конструкторе. После этого поле не могут изменить ни сам класс, ни его клиенты. Допустим, для графического приложения нужно отслеживать разрешение экрана. Решить эту проблему с помощью const нельзя, так как до периода выполнения приложение не может определить разрешение экрана у пользователя. Поэтому для этой цели лучше всего использовать такой код:
using System;
class GraphicsPackage {
public readonly int ScreenWidth; public readonly int ScreenHeight;
public GraphicsPackageO {
this.ScreenWidth = 1024;
this.ScreenHeight = 768; } }
/^
class ReadOnlyApp /
{
public static void Main() {
GraphicsPackage graphics = new GraphicsPackageO; Console.WrlteLine("Ширина = {0}, Высота = {1}", graphics.ScreenWidth, graphics.ScreenHeight); > }
На первый взгляд кажется, что это то, что нужно. Но здесь одна маленькая проблема: определенные нами неизменяемые поля являются полями экземпляра, а значит, чтобы задействовать эти поля, пользователю придется создавать экземпляры класса. Может, это и не проблема, и этот код может даже пригодиться, когда значение неизменяемого поля определяется способом создания экземпляра класса. Но если вам нужна константа, по определению статическая, но инициализируемая в период выполнения? Тогда нужно определить поле с обоими модификаторами — static и readonly, а затем создать особый — статический — тип конструктора. Статические конструкторы (static constructor) используются для инициализации статических, неизменяемых и других полей. Здесь я изменил предыдущий пример так, чтобы сделать поля, определяющие разрешение экрана, статическими и неизменяемыми, а также добавил статический конструктор. Обратите внимание на ключевое слово static, добавленное к определению конструктора:
using Systero;
class GraphicsPackage {
public static readonly int ScreenWidth;
public static readonly int ScreenHeight;
static GraphicsPackageO {
// Здесь будет код для расчета разрешения экрана. ScreenWidth = 1024; ScreenHeight = 768; }
}
class ReadOnlyApp
{
public static void MainQ <
Console.WriteLine("Ширина = {0}, Высота = {1}", GraphicsPackage.ScreenWidth, GraphicsPackage.ScreenHeight); } }
Очистка объектов и управление ресурсами
Возможность обеспечивать очистку и освобождение ресурсов после завершения
исполнения компонентов — одна из самых важных функций системы на основе компонентов.
Под "очисткой и освобождением ресурсов" я понимаю своевременное освобождение
ссылок на другие компоненты и освобождение ресурсов, количество которых невелико
или ограничено, за которые идет конкуренция (например, соединения с базой данных,
описатели файлов и коммуникационные порты). Под "завершением" я понимаю
тот момент, когда объект более не используется. В C++ очистку осуществляет деструктор
(destructor) объекта — определенная для каждого объекта C++ функция, автоматически
выполняемая при выходе объекта из области видимости. В мире Microsoft .NET очистку
объектов автоматически производит .NET Garbage Collector (GC). В силу некоторых
причин эта стратегия противоречива, поскольку в отличие от предсказуемости C++
исполнение кода завершения объекта в решениях .NET основано на модели с отложенными
вычислениями ("lazy" model). GC использует фоновые потоки, определяющие,
что ссылок на объект больше не осталось. Другие потоки GC в свою очередь отвечают
за исполнение кода завершения данного объекта. Чаще всего это то, что нужно,
но это далеко не оптимальное решение, когда мы имеем дело с ресурсами, которые
необходимо своевременно освобождать в предсказуемом порядке. И решить эту проблему
ой как нелегко. В этом разделе я опишу проблемы, связанные с завершением объектов
и управлением ресурсами, и расскажу, в чем заключается ваша роль в создании
объектов с предсказуемым сроком жизни. [Этот раздел в значительной степени построен
на основе замечательного разъяснения управления ресурсами в С#, которое сделал
в открытом сетевом форуме Брайан Гэрри (Brian Harry), член команды разработчиков
Microsoft .NET. Я хочу поблагодарить его за разрешение на использование сделанного
им разъяснения.]
Несколько лет назад, в самом начале работы над проектом .NET, активно дебатировалась проблема управления ресурсами. Первые участники создания .NET пришли из команд, занятых в разработке COM, Microsoft Visual Basic и из др. Одной из самых серьезных была проблема способа подсчета ссылок, включая циклы и ошибки, возникающие при их неверном использовании. Примером может служить проблема циклических ссылок, когда в одном объекте содержится ссылка на другой объект, содержащий обратную ссылку на первый. Вопрос в том, когда и как один из них освободит циклическую ссылку. Если не освободить одну или обе ссылки, возникает утечка памяти, обнаружить которую чрезвычайно трудно. Каждый, кому привелось много работать с СОМ, может рассказать массу историй о срыве графиков из-за времени, затраченного на "отлов" ошибок, возникших при подсчете ссылок. Отдавая отчет в том, что проблемы подсчета ссылок довольно обычны для приложений, основанных на компонентах (таких как СОМ-приложения), Microsoft выступила с тем, чтобы в .NET предоставить универсальное решение.
Первоначальное решение было основано на автоматическом подсчете ссылок. Это решение также включало в себя методы автоматического обнаружения и обработки циклов. Кроме того, команда .NET рассмотрела возможность добавления сбора установившихся (conservative) ссылок, трассировки и алгоритмов GC, которые могли бы подобрать единственный объект, не выполняя трассировки всего графа объекта. Но в силу всяких причин — ниже я остановлюсь на различных подходах — было решено, что в типичном случае не будут задействованы все эти решения.
Одним из крупных препятствий на заре разработки .NET была необходимость поддержания высокой степени совместимости с Visual Basic. Поэтому решение должно быть полным и прозрачным без изменения семантики самого языка Visual Basic. Окончательным решением стала модель на основе контекста, в которой все, что существует в определенном контексте, использует подсчет ссылок поверх GC, а все, что вне, будет использовать только GC. Это на самом деле помогло справиться с некоторыми проблемами при разветвлении (см. ниже), но не дало хорошего решения при использовании кода на разных языках. Итак, было принято решение внести серию изменений в язык Visual Basic, чтобы модернизировать его и придать ему дополнительные возможности. Частью этого решения стал отказ от совместимости с требованиями Visual Basic ко времени жизни. Оно также в общем положило конец исследованиям в области проблем детерминированного времени жизни.
От истории перейдем к детерминированному завершению. Как только определено, что объект более не используется, код его завершения освобождает удерживаемые объектом ссылки на другие объекты. Затем этот каскад процесса завершения естественным образом проходит через граф объектов, начиная с верхнего объекта. В идеале это должно работать как в случае совместно, так и индивидуально используемых объектов.
Заметьте: никаких обещаний насчет времени не дается. Обнаружив, что ссылка больше не используется, поток GC не будет предпринимать больше никаких действий, пока не выполнится код завершения объекта. Однако при обработке всегда может случиться переключение контекста процессора. Это значит, что, с точки зрения приложения, до завершения данного этапа может пройти неопределенно долгое время. Как отмечено выше, очень часто приложению могут быть небезразличны своевременность и порядок исполнения кода завершения. Эти ситуации в общем случае связаны с ресурсами, за которые идет интенсивная конкуренция. Ниже приводятся некоторые примеры таких ресурсов, которые объект должен освободить, как только он больше не используется.
Сбор мусора с помощью счетчика ссылок
Счетчик ссылок выполняет разумную функцию, зачастую обеспечивая детерминированное
завершение. Но в ряде совсем уж редких случаев, одним из примеров которых служат
циклы, счетчик ссылок не может выполнять эту функцию. Фактически при прямом
подсчете ссылок никогда не собираются объекты, задействованные в циклах.
Большинство из нас, набив достаточно шишек, освоили методики, позволяющие
бороться с этим вручную. Но такие методики являются нежелательным источником
ошибок. К тому же, начав размещать объекты по разным потокам, вы рискуете получить
реентерабельность потоков и, таким образом, внести в свою программу изрядную
долю недетерминированности. Некоторые будут доказывать, что коль скоро вы передаете
ссылку на объект за пределы сферы прямого контроля тесно связанной программы,
вы теряете возможность детерминированного завершения, поскольку вы не имеете
ни малейшего понятия, когда этот "далекий" код освободит переданную
ссылку, и сделает ли он это вообще. Другие считают, что сложным системам, зависимым
от порядка завершения сложных графов объектов, изначально присуща хрупкость
конструкции и, весьма вероятно, это приведет к существенным проблемам в сопровождении
по мере эволюции кода.
Сбор мусора с помощью трассировки
Трассирующий сборщик подает еще более слабую надежду, чем счетчик ссылок. В силу некоторых причин эта система более интенсивно использует отложенные вычисления при исполнении кода завершения. У объектов есть завершители (finalizers) — методы, которые исполняются, когда объект более недоступен программе. Плюс трассировки в том, что циклы для нее не проблема, а еще больший плюс — что присваивание ссылки представляет собой простую операцию перемещения (об этом чуть позже). За это приходится расплачиваться гарантиями того, что код завершения будет исполнен "сразу" по выходу ссылки из употребления. Ну, а какие гарантии тогда вообще даются? Суть в том, что для корректно работающих программ вызываются завершители объектов (сбойные программы терпят крах или переводят завершитель в бесконечный цикл). Онлайновая документация в этом вопросе проявляет тенденцию к чрезмерной осторожности. Но если у объекта есть метод Finalize, система его вызовет. Проблемы детерминированного завершения это не решает, но важно понимать, что при этом все же производится сбор ресурсов и завершители являются эффективным средством предотвращения утечки ресурсов в программе.
Производительность связана с проблемой завершения, и поэтому является важным
аспектом. Команда разработчиков .NET считает, что должен существовать некоторый
трассирующий сборщик, задача которого — обработка циклов (на которую тратится
большая часть ресурсов, потребляемых сборщиком). Также считается, что расход
ресурсов, связанный с работой счетчика ссылок, может серьезно повлиять на производительность
исполнения кода. К счастью, в контексте всех объектов, выделенных работающей
программой, число объектов, которым действительно требуется детерминированное
завершение, невелико. Однако обычно нелегко изолировать размер расхода ресурсов
исключительно данным объектом или набором объектов. Рассмотрим фрагмент псевдокода,
выполняющего простое присваивание ссылки при использовании трассирующего сборщика,
а затем псевдокод, применяющий счетчик ссылок:
// Трассировка, а = Ь;
И все. Компилятор выполняет единственную команду перемещения, а при некоторых обстоятельствах ее можно и вовсе опустить при оптимизации.
// Счетчик ссылок, if (a != null)
if (InterlockedDecrement(ref a.m_ref) == 0) a. FinalReleaseO;
if (b != null)
Interlockedlncrement(ref b.m_ref);
a = b;
Этот код сильно раздут, его рабочий набор больше, и производительность исполнения неприлично мала, особенно при двух взаимоблокирующих командах. Разбухание кода можно ограничить, поместив эти вещи во вспомогательный метод, еще больше увеличив при этом вложенность кода. Кроме того, когда вы разместите все необходимые блоки try, пострадает генерация кода, так как в силу некоторых причин из-за присутствия кода обработки исключений у средства оптимизации кода будут "связаны руки". Это верно и для C++, где нет такого управления ресурсами. Стоит отметить и то, что размер каждого объекта при этом возрастает на 4 байта из-за дополнительного поля счетчика ссылок, снова увеличивая использование памяти.
Два примера, любезно предоставленные командой разработчиков .NET, демонстрируют
связанный с этим расход ресурсов. Они выполняют тестовые циклы, выделение объектов,
два присвоения и выводят за пределы видимости одну из ссылок. Результаты работы
этих приложений, как и результаты любых тестов, можно интерпретировать субъективно.
Можно приводить даже такие аргументы, что в контексте этой программы большая
часть операций по подсчету ссылок может быть опущена в результате оптимизации.
Может, это и так, но нам нужно лишь продемонстрировать сам эффект. В настоящей
программе выполнить оптимизацию подобного рода трудно, если вообще возможно.
Фактически программисты на C++ делают такую оптимизацию вручную, что ведет к
возникновению ошибок при подсчете ссылок. Поэтому в настоящих программах отношение
"присвоение/выделение памяти" много выше, чем в приведенных здесь
примерах.
Вот первый пример, ref^gc.cs. Эта версия использует трассирующий GC:
using System;
public class Foo {
private static Foo m_f;
private int mjnember;
public static void Main(String[] args)
{
int ticks = Environment.TickCount;
Foo f2 = null;
for (int i=0; i < 10000000; ++i) {
Foo f = new FooQ;
// Присваивание f2 значения статического объекта. f2 = m_f;
// Присваивание f статическому объекту. m_f = f;
// f выходит за пределы видимости. }
// Присваивание f2 статическому объекту. m_f = f2;
// f2 выходит за пределы видимости.
/
/ / ticks = Environment.TickCount - ticks;
/
Console.WriteLine("Ticks = {0}", ticks);
>
public Foo() { } }
А вот и второй пример, refjm.cs — версия, использующая подсчет ссылок с помощью взаимоблокирующих операций для защиты потоков:
using System;
using System.Threading;
public class Foo {
private static Foo m_f;
private int mjnember;
private int m_ref;
public static void Main(String[] args) <
int ticks = Environment.TickCount;
Foo f2 = null;
for (int i=0; i < 10000000; ++i) {
Foo f = new Foo();
// Присваивание f2 значения статического объекта.
if (f2 != null)
{
if (Interlocked.Decrement(ref f2.m_ref) == 0)
f2.Dispose(); } if (m_f != null)
Interlocked.Increment(ref m_f.m_ref); f2 = m_f;
// Присваивание f статическому объекту.
if (m_f != null)
{
if (Interlocked.Decrement(ref m_f.m_ref) == 0)
m_f. DisposeO; > if (f != null)
Interlocked.Increment(ref f.m_ref); m_f = f;
// f выходит за пределы видимости, if (Interlocked.Decrement(ref f.m_ref) == 0) f. DisposeO;
}
// Присваивание f2 статическому объекту, if (m_f != null)
{
if (Interlocked.Decrement(ref m_f.m_ref) == 0)
m_f. DisposeO; } if (f2 != null)
Interlocked.Increment(ref f2.m_ref); m_f = f2;
// f2 выходит за пределы видимости, if (Interlocked.Decrement(ref f2.m_ref) == 0) f 2. DisposeO;
ticks = Environment.TickCount - ticks; Console.WriteLine("Ticks = {0}", ticks); }
public Foo() {
m_ref = 1; }
public virtual void DisposeO
{
}
}
Здесь присутствует лишь один поток, и нет конкуренции за ресурсы шины, чуо
делает рассматриваемый случай "идеальным". Вероятно, вы сможете'отрегулировать
программу лучше, но ненамного. Замечу также, что Visual Basic исторически не
должен был беспокоиться о применении взаимоблокирующих операций для подсчета
ссылок (хотя Visual C++ — должен). В Прежних выпусках Visual Basic компонент
запускался в од-нопоточном окружении, гарантирующем исполнение лишь одного потока
в один момент времени. Одна из задач Visual Basic.NET — поддержка многопоточного
программирования, а другая — избавление от сложности, присущей моделям многопоточности
СОМ. Но тем из вас, кому требуется версия, не использующая префиксы блокировки,
будет интересен следующий пример, также предоставленный командой разработчиков
.NET. ref_rs.cs, — версия, применяющая счетчик ссылок, которая "считает",
что она работает в однопоточном окружении. Эта программа работает не так медленно,
как многопоточная версия, но все же медленнее версии, использующей GC.
using System;
public class Foo {
private static Foo m_f;
private int mjnember;
private int m_ref;
public static void Main(String[] args) {
int ticks = Environment.TickCount;
Foo f2 = null;
for (int 1=0; i < 10000000; ++i) {
Foo f = new Foo();
// Присваивание f статическому объекту.
if (f2 != null)
{
if (-f2.m_ref == 0)
f2.Dispose(); } if (m_f l= null)
++m_f.m_ref; f2 = m_f;
// Присваивание f статическому объекту.
if (m_f l= null)
{
if (-m_f.m_ref == 0)
m_f .DisposeO; } if (f l= null)
++f.m_ref; m_f = f;
// f выходит за пределы видимости, if (-f.m_ref == 0)
f. DisposeO; }
// Присваивание f2 статическому объекту.
if (m_f != null)
{
if (-m_f.m_ref == 0)
m_f .DisposeO; } if (f2 != null)
++f2.m_ref; m_f = f2;
// f2 выходит за пределы видимости, if (-f2.m_ref == 0) f 2. DisposeO;
ticks = Environment.TickCount - ticks; Console.WriteLine("Ticks = {0}", ticks); >
public Foo() {
m_ref = 1;
} "~""^^\
"x
public virtual void DisposeO \ч
{ X > }
Видно, что переменных здесь хватает. В результате запуска всех трех приложений, можно отметить, что версия, использующая GC, работала практически вдвое быстрее однопоточной версии, использующей счетчик ссылок, и вчетверо — версии, использующей счетчик ссылок и префиксы блокировки. Лично у меня получились такие результаты (заметьте: числа представляют собой средние значения, полученные на IBM ThinkPad 570 с помощью компилятора .NET Framework SDK Beta 1):
GC Version (ref_gc) 1162ms
Ref Counting (ref_rm)(raulti-threaded) 4757ms
flef Counting (ref_rs)(single-threaded) 1913ms
Согласитесь, что совершенное решение этой проблемы — создать систему, в которой каждый объект не требует много памяти, его легко использовать и восстанавливать. В такой идеальной системе каждый объект сразу, как только программист сочтет, что объект больше не нужен (независимо от того, существуют ли циклы), удаляется детерминированным упорядоченным способом. Посвятив бессчетное количество часов решению этой проблемы, команда разработчиков .NET считает, что это можно сделать, лишь комбинируя трассирующий GC со счетчиком ссылок. Данные теста говорят, что подсчет ссылок требует слишком больших затрат ресурсов, чтобы использовать его при решении задач общего назначения для всех объектов среды программирования. Вложенность кода, сам код и данные при этом больше. Если затем прибавить к этим и без того высоким затратам ресурсов дополнительную стоимость реализации трассирующего сборщика для обработки циклов, то станет ясно, какую непомерную цену приходится платить за управление памятью.
На заре разработки .NET были исследованы различные методы, чтобы отыскать способ улучшения производительности подсчета ссылок. До этого уже были созданы системы на основе счетчика ссылок с приемлемой производительностью. Увы, после изучения литературы стало ясно, что повышение производительности в подобных системах достигалось за счет снижения их детерминированности.
С другой стороны, замечу, что для программ на С++/СОМ это не проблема. Там,
где от программиста требуется явное управление памятью, самые производительные
С++-программы, использующие СОМ, внутренне применяют классы C++. Программисты
на C++ в общем случае используют СОМ, только чтобы создавать клиентские интерфейсы.
Это ключевая характеристика, обеспечивающая высокую производительность этих
программ. Но это, очевидно, не задача .NET, где связанные с управлением памятью
вопросы должны решаться GC, а не программистом.
Итак, решения по управлению ресурсами не могут дать все и сразу. А если реализовать детерминированное завершение лишь для тех объектов, для которых оно, по вашему мнению, действительно нужно? Команда разработчиков .NET долго размышляла об этом. Не забывайте, что все это происходило в контексте необходимости точного дублирования семантики Visual Basic 6 в новой системе. Большая часть сделанных выводов применима и сейчас, но некоторые давно отброшенные идеи теперь выглядят привлекательно (например, методы управления ресурсами как альтернатива "прозрачной" семантике времени жизни в Visual Basic 6).
Первая попытка заключалась просто в том, чтобы пометить класс как требующий детерминированного завершения атрибутом или наследованием от "специального" класса. В результате этого производится подсчет ссылок на объект. Было проверено множество решений как с подклассами System.Object, так и с возможностью замены корня иерархии классов другим классом, чтобы объединить подход с подсчетом ссылок с альтернативными. Увы, при этом встретился ряд непреодолимых проблем, описанных ниже.
Объединение
Каждый раз, когда вы берете объект, требующий детерминированного завершения, и сохраняете в объекте, который этого не требует, вы теряете детерминированность (так как детерминированность не передается). Эта проблема наносит удар в самое сердце иерархии классов. Как быть, например, с массивами? Если вам нужен массив детерминированных объектов, то сами массивы должны быть детерминированными. А как насчет коллекций и хэш-таблиц? Список продолжается: забегая вперед, скажу, что подсчет ссылок используется всей библиотекой классов, что делает невозможным выполнение поставлётгей-задачя.
Другой альтернативой была бифуркация (т. е. разделение на две ветви) библиотеки классов .NET Framework и появление двух версий многих типов классов, например, детерминированных и недетерминированных массивов. Однако стало ясно, что наличие двух копий всей модели приведет к путанице, производительность в этом случае будет ужасной, так как будут загружаться две копии класса, и в конце концов это будет непрактично.
Изучались специфические решения для специфических классов, но ни одно из них
даже не приблизилось к тому, чтобы быть решением общего назначения, масштабируемым
в рамках всей модели. Обдумывали вариант, в котором фундаментальная проблема
— случай, когда система теряет детерминированность, если недетерминированный
объект содержит ссылку на детерминированный, — рассматривалась как ошибочная
ситуация. Однако был сделан вывод, что это ограничение затруднит написание реальных
программ.
Приведение
Во время приведения типов возникают сходные проблемы. Попробуйте ответить на такие вопросы: могу ли я привести детерминированный объект к System.Object! Если так, то будут ли при этом подсчитываться ссылки? Если да, то для любой сущности будет вестись подсчет ссылок, нет — объект теряет детерминированность, если же ответ — "ошибка", нарушается фундаментальная предпосылка, согласно которой System.Ob-ject — корень иерархии объектов.
Интерфейсы
Теперь вы видите, насколько сложна проблема. Если детерминированный объект реализует интерфейсы, то подсчитываются ли ссылки, типизированные как интерфейсные ссылки? Если да, то для всех реализующих интерфейсы объектов производится подсчет ссылок (заметьте, что System.Int32 реализует интерфейсы). Если нет, объект опять же теряет детерминированность. Если же ответ — "ошибка", детерминированный объект не может реализовывать интерфейсы. А если ответ звучит так: "это зависит от того, помечен ли интерфейс как детерминированный", то возникает еще одна проблема бифуркации. Интерфейсы не предназначены для определения времени жизни. Что, если кто-то реализует API, принимающий интерфейс ICollection и реализующий его ваш объект должен быть детерминированным, а сам интерфейс не был определен таким образом? При этом вам сильно не повезет. Согласно этому сценарию, будет нужно определить два интерфейса — детерминированный и недетерминированный, где каждый метод будет определен дважды (в каждом из интерфейсов). Вы не поверите, но рассматривалась даже идея создания средств автоматической генерации двух версий методов. Так, мысль погрязла в огромной сложности проблемы, и эта идея была отброшена.
Итак, на чем мы остановились? На данный момент мы уже рассмотрели детерминированное завершение, которое работает так:
Этот подход действительно помогает решить проблему циклических ссылок, но влечет за собой другие проблемы. Например, нет гарантии, что метод Finalize будет вызван когда-нибудь в течение периода выполнения! А это именно то, что в детерминированном завершении понимается под словом детерминированное. Короче, вопрос ставится так: как выполнить очистку и убедиться, что отвечающий за очистку код вызывается каждый раз?
В случае освобождения дефицитных ресурсов Microsoft предлагает решение, в основе которого лежит метод Dispose. Эта схема рекомендует, чтобы у объекта был открытый метод с универсальным именем, например Cleanup или Dispose, который пользователь согласно инструкции должен вызывать, закончив применять объект. При этом всю очистку в этом методе определяет разработчик класса. Фактически вы увидите, что для этой цели у многих классов из библиотеки .NET Framework реализован метод Dispose. В качестве примера приведу выдержку из документации класса System. Wmforms. Traylcon: "при завершении использования Tray Icon вызовите метод Dispose".
Как я сказал в главе 1, чтобы построить — в терминах данных или поведения — класс на основе другого класса, применяется наследование. Оно принадлежит к правилам заменяемости, а именно к тому, которое гласит, что производный класс может быть заменен базовым. Примером этого может служить написание иерархии классов базы данных. Допустим, вам был нужен класс для обработки баз данных Microsoft SQL Server и Oracle. Поскольку эти базы различны, вам понадобится по классу для каждой из БД. Однако в обеих БД достаточная доля общей функциональности, которую можно поместить в базовый класс, а два других класса сделать его производными, при необходимости подменяя или изменяя поведение, унаследованное от базового класса.
Чтобы унаследовать один класс от другого используется синтаксис:
class <производный_класс> : <базовый_класс> Вот пример того, на что может быть похож такой пример с базами данных:
using System;
class Database {
public Database()
{
CommonField = 42;
}
public int CommonField; public void CommonMethodO {
Console.WriteLine("Database.Common Method"); } }
class SQLServer : Database {
public void SomeMethodSpecificToSQLServerO
{
Console. WriteLineC' SQLServer. SomeMethodSpecific To SQLServer ");
> }
class Oracle : Database <
public void SomeMethodSpecificToOracleO
{
Console.WriteLine("Oracle.SomeMethodSpecificToOracle");
} }
class InheritanceApp {
public static void Main() {
SQLServer sqlserver = new SQLServerO;
sqlse rve r.SomeMethodSpeci ficToSQLServe r();
sqlserver. CommonMethodO;
Console.WriteLine("Inherited common field = {0}",
sqlserver.CommonField); } >
При компиляции и исполнении этого приложения получится следующий результат:
SQLSe rve r.SomeMethodSpecificToSQLServe r Database.Common Method Inherited common field =42
Заметьте: методы Database.CommonMethod и Database.CommonField теперь являются частью определения класса SQLServer. Так как классы SQLServer и Oracle происходят от базового класса Database, оба наследуют практически все его члены, определенные как public, protected, или internal. Единственное исключение — конструктор, который не может быть унаследован. У каждого класса должен быть реализован собственный конструктор, независимый от базового класса.
Подмена методов описана в главе 6. Однако замечу, что подмена методов позволяет изменять реализацию унаследованных от базового класса методов. Абстрактные классы интенсивно используют подмену методов (см. главу 6).
Использование нескольких интерфейсов
Позвольте мне придерживаться ясности в изложении этого вопроса, поскольку множественное наследование обсуждали во многих группах новостей и рассылках: С# не поддерживает множественное наследование. Но вы можете объединять характеристики поведения Нескольких программных сущностей, реализовав несколько интерфейсов. Об интерфейсах и способах работы с ними см. главу 9. А пока вы можете думать об интерфейсах на С#, как вы думаете о СОМ-интерфейсах. \ С учетом этого следующая программа ошибочна: \
class Foo
{
}
class Bar
<
>
class MITest : Foo, Bar {
public static void Main ()
{
} }
Допущенная в этом примере ошибка связана со способом реализации интерфейсов. Интерфейсы, выбранные для реализации, перечисляются после базового класса данного класса. В этом примере компилятор С# думает, что Ваг должен быть интерфейсом. Поэтому компилятор С# выдаст вам сообщение об ошибке:
'Bar':type in interface list is not an interface
Следующий, более реалистичный, пример абсолютно верен, так как класс MyFancyGrid происходит от Control и реализует интерфейсы ISeria-lizable и IDataBound:
class Control
{
}
interface ISerializable
{
}
interface IDataBound
{
}
class MyFancyGrid : Control, ISerializable, IDataBound
{
}
Главное здесь то, что единственным способом реализации чего-либо, напоминающим множественное наследование, на С# является применение интерфейсов.
Если вы хотите быть уверенными, что класс никогда не будет использован как
базовый, при определении класса примените модификатор sealed. Единственное
ограничение: абстрактный класс не может быть изолированным, так как в силу своей
природы абстрактные классы предназначены для использования в качестве базовых.
И еще. Хотя изолированные классы предназначены для предотвращения непреднамеренного
создания производных классов, определение класса как изолированного позволяет
выполнить оптимизацию периода выполнения. В частности, поскольку компилятор
гарантирует отсутствие у класса любых производных классов, есть возможность
преобразования виртуальных вызовов для изолированного класса в невиртуальные
вызовы. Вот пример определения класса как изолированного:
using System;
sealed class MyPoint {
public HyPoint(int x, int y) {
this.x = x; this.у = у; }
private int X; public int x {
get
{
return this.X;
} set
<
this.X = value;
} }
private int Y; / public int у /
get {
return this.Y;
}
set
{
this.Y = value; } } >
class SealedApp {
public static void Main() {
MyPoint pt = new MyPoint(6,16);
Console.WriteLine("x = {0}, у = {1}", pt.x, pt.y); } }
Я использовал модификатор доступа private для внутренних членов классами Y. В результате применения модификатора protected компилятор выдаст предупреждение, так как защищенные члены видимы производным классам, а, как вам известно, у изолированных производных классов нет.
Подведем итоги
Понятие о классах и их взаимоотношений с объектами — основа идеи объектного программирования. Объектно-ориентированные функции С# — наследие C++, дополненное и улучшенное функциями .NET Framework. Разработчиков серьезно беспокоит управление ресурсами в управляемых системах вроде CLR. CLR стремится освободить программистов от нудной работы по подсчету ссылок с помощью сбора мусора на основе детерминированного завершения. Работа с наследованием в С# также отличается от таковой в C++. Хотя поддерживается лишь одиночное наследование, разработчики все же могут задействовать преимущества множественного наследования, реализуя набор интерфейсов.