Логин:
Пароль:
Меню
Главная Программы Исходники Электронные книги FAQ C# Online учебник Android Java FAQ Android, Java Помощь сайту Наши баннеры О нас Связь с администрацией
Облако тегов

Показать все теги
Архив
  • Февраль 2018
  • Декабрь 2017
  • Май 2016
  • Апрель 2015
  • Март 2015
  • Март 2013
  • Март 2012

  • Классы

    ГЛАВА 5
    Классы





    Классы — сердце каждого объектно-ориентированного языка. Как вы помните, класс представляет собой инкапсуляцию данных и методов их обработки (см. главу 1). Это справедливо для любого объектно-ориентированного языка и отличаются они в этом плане лишь типами тех данных, которые можно хранить в виде членов, а также возможностями классов. В том, что касается классов и многих функций языка, С# кое-что заимствует из C++ и Java, и привносит немного изобретательности, помогающей найти элегантные решения старых проблем.



    В этой главе я сначала опишу основы определения классов на С#, включая члены-экземпляров, модификаторы доступа, конструкторы и инициализационные списки, затем перейду к определению статических членов и раскрою разницу между постоянными и неизменяемыми полями. Потом я расскажу о деструкторах и о детерминированном завершении. В конце главы мы вкратце обсудим наследование и классы С#.

     
    Определение классов



    Синтаксис определения классов на С#, прост, особенно если вы программируете на C++ или Java. Поместив перед именем вашего класса ключевое слово class, вы вставляете члены класса, заключенные в фигурные скобки, например:



    class Employee {



    private long employeeld; }



    Как видите, этот простейший класс с именем Employee содержит единственный член — employeeld. Заметьте: имени члена предшествует ключевое слово private — это модификатор доступа (access modifier). В



    С# определено четыре модификатора доступа, и совсем скоро я расскажу о них.

     
    Члены класса


    В главе 4 я рассказал о типах, определенных в CIS (Common Type System). Эти
    типы поддерживаются как члены классов С# и бывают следующих видов.

    • Поле. Так называется член-переменная, содержащий некоторое значение.
      В ООП поля иногда называют данными объекта. К полю можно применять несколько
      модификаторов в зависимости от того, как вы собираетесь это поле использовать.
      В число модификаторов входят static, readonly и const. Ниже
      мы познакомимся с их назначением и способами их применения.
    • Метод. Это реальный код, воздействующий на данные объекта (или поля).
      В этой главе мы сосредоточимся на определении данных класса. Подробнее о методах
      см. главу 6.
    • Свойства. Их иногда называют "разумными" полями (smart
      fields), так как они на самом деле являются методами, которые клиенты класса
      воспринимают как поля. Это обеспечивает клиентам большую степень абстрагирования
      за счет того, что им не нужно знать, обращаются ли они к полю напрямую или
      через вызов метода-аксессора. Подробнее о свойствах см. главу 7.
    • Константы. Как можно предположить, исходя из имени, константа —
      это поле, значение которого изменить нельзя. Ниже мы обсудим константы и сравним
      их с сущностью под названием неизменяемые (readonly) поля.
    • Индексаторы. Если свойства — это "разумные" поля, то
      индексаторы _ это "разумные" массивы, так как они позволяют индексировать
      объекты методами-аксессорами get и set. С помощью индексатора
      легко проиндексировать объект для установки или получения значений. Об индексаторах
      см. главу 7.
    • События. Событие вызывает исполнение некоторого фрагмента кода.
      События — неотъемлемая часть программирования для Microsoft Windows. Например,
      события возникают при движении мыши, щелчке или изменении размеров окна. События
      С# используют ту же стандартную модель публикации/подписки (publish/subscribe),
      что и в MSMQ (Microsoft Message Queuing) и асинхронной модели событий СОМ+,
      которая дает приложению средства асинхронной обработки событий. Но в С# это
      базовая концепция, встроенная в язык. Об использовании событий см. главу 14.
    • Операторы. Используя перегрузку операторов С#, можно добавлять к
      классу стандартные математические операторы, которые позволяют писать более
      интуитивно понятный код. О перегрузке операторов см. главу 13.

     
    Модификаторы доступа



    Теперь, зная, что типы могут быть определены как члены класса С#, познакомимся с модификаторами, используемыми для задания степени "видимости", или доступности данного члена для кода, лежащего за пределами его собственного класса. Они называются модификаторами доступа (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



    У каждого приложения на С# должен быть метод 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 которого должен быть
    задействован. Вот пример, в котором я создал два класса, содержащих методы 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, конструкторы базового
    класса вызываются прямо перед исполнением первой строки конструктора. Эти инициализаторы
    конструкторов позволяют задавать класс и подлежащий вызову конструктор. Они
    бывают двух видов.

    • Инициализатор в виде base(...) активизирует конструктор базового
      класса текущего класса.
    • Инициализатор в виде this(...) позволяет текущему базовому классу
      вызвать другой конструктор, определенный в нем самом. Это полезно, когда вы
      перегрузили несколько конструкторов и хотите быть уверенными, что всегда будет
      вызван конструктор по умолчанию. О перегруженных методах см. главу 6, здесь
      же мы приведем их краткое и не совсем точное определение: перегруженными называются
      два и более методов с одинаковым именем, но с различными списками аргументов.


    Чтобы увидеть порядок событий в действии, обратите внимание на следующий код: он сначала исполнит конструктор класса А, а затем конструктор класса В:



    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 не будет предпринимать больше никаких действий,
    пока не выполнится код завершения объекта. Однако при обработке всегда может
    случиться переключение контекста процессора. Это значит, что, с точки зрения
    приложения, до завершения данного этапа может пройти неопределенно долгое время.
    Как отмечено выше, очень часто приложению могут быть небезразличны своевременность
    и порядок исполнения кода завершения. Эти ситуации в общем случае связаны с
    ресурсами, за которые идет интенсивная конкуренция. Ниже приводятся некоторые
    примеры таких ресурсов, которые объект должен освободить, как только он больше
    не используется.

    • Память. Память, занятая графом объекта, после освобождения быстро
      возвращается в пул для последующего использования.
    • Описатели окон. Объем памяти, занимаемой объектом "окно",
      в 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 и реализующий его ваш объект должен быть детерминированным, а сам интерфейс не был определен таким образом? При этом вам сильно не повезет. Согласно этому сценарию, будет нужно определить два интерфейса — детерминированный и недетерминированный, где каждый метод будет определен дважды (в каждом из интерфейсов). Вы не поверите, но рассматривалась даже идея создания средств автоматической генерации

    Популярные статьи
    Online Учебник по С# Бесплатная альтернатива Microsoft Visual Studio .NET для новичков Язык программирования C# 2005 и платформа .NET 2.0 ASP.NET MVC 4 Framework с примерами на C# 5.0 для профессионалов. 4-е изд. Язык программирования C# 5.0 и платформа .NET 4.5
    Реклама