Одна из тенденций, которую поддерживают многие относительно новые языки программирования (Java, C#), практически все скриптовые языки, и которой не было в относительно старых языках (C, Pascal, отчасти C++) - это автоматизация управления памятью. Действительно, с точки зрения надежности и скорости разработки программ любая автоматизация, которая избавляет программиста от однотипных забот, где ему освобождать занятую на каждый чих память — это хорошо. Да и с точки зрения производительности самой работающей программы тот проигрыш, который накладывают автоматические системы, становится все более микроскопическим благодаря "закону Мура", а например в интерактивных пользовательских прграммах производительность вообще редко выходит на первый план (большую часть времени пользователь, очевидно, тормозит куда больше процессора).

Так вот. Подходов к этому самом автоматическому управлению памятью существует больше одного.

Сборка мусора

Java, C#, Python и еще разные системы используют так называемую "сборку мусора" (она же GC — Garbage Collection). Суть ее состоит в том, что система программирования регистрирует у себя все ссылки на выделенную память (переменные, указатели) и в некоторые моменты затишья активности программы вызывает специальный механизм, который обходит все эти ссылки, а также все ссылки из объектов, на которые ссылаются эти ссылки, и помечает реально доступные из программы занятые участки памяти. Те, которые остались непосчитанными — удаляет.

Способ этот подвергается критике, в основном, за то, что момент освобождения ресурсов непредсказуем. Причем, если речь идет о выделенной памяти, это беда небольшая: умная система программирования может и принудительно прогнать очередную сборку, когда на очередную запрошенный объект памяти не хватает. Но вот с другими видами ресурсов дело обстоит хуже. Например, если у вас пропадает из области видимости объект, который за собой закрывает файл, то это совсем не означает, что этот файл закроется прямо сейчас. И следовательно, вы не можете открыть этот файл в другом объекте. Это, конечно, тоже обходится: например, вызов принудительной сборки делается доступным программисту. И, типа, если вы считаете, что вот тут у вас опасное место, то можете это вызвать и быть уверенными, что дальше все будет хорошо. Только есть проблемы. Во-первых, из-за той самой непредсказуемости в ваших тестовых примерах вряд ли проявятся те самые опасные места, которые обязательно вылезут у пользователей. Во-вторых... кто тут что-то говорил про автоматическое управление памятью?

М-да... Ну да ладно. По крайней мере, вы поняли, что GC я не люблю :-). Я, на самом деле, уверен, что со всяческими узкими местами как-то борются, что, в общем-то, подтверждается тем, что системы на Java вполне себе работают. Но речь-то моя не о том. Это, вообще, все было вступление только.

Небольшое введение в интерфейсы

В моей любимой Delphi есть и работает другой способ избавления от забот по освобождению памяти. Он неразрывно связан с программной моделью интерфейсов: набора методов объекта, предназначенных для выполнения какой-то конкретной задачи. Одной из особенностей их использования является то, что код, использующий интерфейс, вообще ничего не знает о реальном типе (классе) объекта, который ему этот интерфейс предоставляет.

Пример. Предположим, вы пользуетесь библиотекой создания отчетов из БД, в которой определен класс... стоп! Совсем даже не класс, а интерфейс, предоставляющий доступ к отчету. Типа такого:

IReport=Interface
  Procedure Preview;
  Procedure Print;
  Function PageCount:Integer;
  Function ReadPrintSetup:TPrintSetup;
End;{IReport}

Похоже на объект, но это не он. Основная разница заключается в том, что реально реализовать эти функции может любой объект, любого класса. И отсюда вытекает, что когда вы получаете собственно, переменную интерфейса:

Function GetReportFromFile(...):IReport;
...
var 
  Report:IReport;
...
Report:=GetReportFromFile(...);

... вы понятия не имеете, был ли реальный объект, реализующий эти функции, создан прямо сейчас специально для вас или он уже существовал раньше.

Вот теперь вы должны не понимать, a как теперь узнать, когда этот объект освобождать? Ведь с обычными классами, когда ваш модуль делает Object:=TObject.Create;, вы, как культурный программист, знаете, что это именно ваша, и ничья другая, обязанность осознать, где именно вы напишете Object.Free. А лучше прямо тут же пойти туда и написать. Чтобы не забыть.

Подсчет ссылок

У интерфейсов это решено еще одной вкуснозвучащей фишкой: "рефкаунтинг" (в смысле, Reference Counting — посдчет ссылок).

Заметьте, вместе с "гарбэдж коллекшн" — это еще одно мегаслово, которым можно набивать себе цену на собеседовании :-)

Суть ее состоит в том, что объект, реализующий интерфейс, имеет у себя переменную, в которой хранится количество пользователей интерфейса в данный момент. И как только значение этой переменной обращается в нуль, объект тут же себя уничтожает. То есть, в противовес традиционной "системы Тараса Бульбы", когда уничтожать объект обязуется тот, кто его породил, здесь первый пользователь, порождающий объект, ничем не отличается от всех остальных, использующих объект.

Да-да, я знаю :-). Я не объяснил этот самый "Kind Of Magic" из предыдущего абзаца, где "как только значение этой переменной обращается в нуль"... Очевидно, значение этой переменной изменятся не само, так не бывает. Его кто-то должен увеличивать, когда появляется новая переменная, указывающая на интерфейс, и уменьшать, когда она исчезает. Делают это сами пользователи интерфейса, именно так, как я и написал: когда создается новая переменная, указывающая на интерфейс, вызывается процедура, увеличивающая счетчик (AddRef), а когда он не нужен — уменьшающая (Release).

Честно, у меня было такое же лицо, как у вас сейчас, когда я узнал о рефкаунтинге впервые. Алена тогда занималась изучением COM'а, где он используется, и читала книжку, в которой прямо таки серьезно предлагалось не забывать вызывать AddRef и Release (увеличение и уменьшение счетчика) вручную при получении и освобождении ссылок. И вот это "новая технология"? М-да...

Но в реальности все оказалось куда как вкуснее. Разумеется, процедуры счетчика вызываются автоматически. В разных языках по-разному. Например, в C++ это реализовано целиком внешними библиотеками так называемых "умных указателей". В Delphi же заботу об этом берет на себя сам язык. Везде, где идет присваивание переменной интерфейсного типа какого-то объекта, у него забирается ссылка на интерфейс и вызывается AddRef. Как только переменная выходит из области видимости, или уничтожается объект, ее содержащий, тому объекту, на который она указывает, вызывается Release. Причем, все это делается неявно и прозрачно. Проиллюстрирую:

var Report:IReport;
begin
  Report:=GetReportFromFile(...); //функция возвращает IReport
  //тут же вызывается Report._AddRef;
  Report.Print;
end; //при выходе из функции менеджер памяти теряет переменную Report и перед этим вызывает Report._Release;

begin
  With GetReportFromFile(...) Do //получаем безымянный IReport - вызывается _AddRef
  Begin
    Preview;
    Print;
  End;{With} //With закончился, вызывается _Release для его переменной
end;

//и даже:

Report:=GetReportFromFile(...) //_AddRef
Report:=GetReportFromFile(...) //сразу же _Release для того, что было в Report раньше и _AddRef для новой ссылки, возвращенной из функции.

Другими словами, при использовании интерфейсов, про что вы можете забыть — так это про освобождение памяти. И заметьте, в отличие от GC вы точно знаете, когда будет вызван деструктор объекта с интерфейсами: тогда, и не позже, когда на него пропадет последняя ссылка. В GC же этот момент отодвигается на неопределенный срок "когда системе будет угодно".

Как говорит один мой друг: "Щасце!".

Проблемы

Но, конечно, не все так радужно. Когда вы начнете пользоваться рефкаунтингом (а вы просто обязаны!), вы будете встречаться со странными багами, который в традиционной модели с владением объектами встречаться просто не могли.

Проблема 1. Это изменение порядка уничтожения объектов. В модели с владением, если объект содержит в себе другие объекты, то при своем уничтожении он их тоже уничтожает. Но в этот момент он сам еще существует в памяти, и зависимые объекты могут расчитывать на то, что если они к нему обратятся из своих деструкторов, ничего кошмарного не произойдет. При рефкаунтинге все наоборот. Если объект-хозяин держит у себя ссылки на интерфейсы зависимых объектов, то они начнут удаляться уже после того, как сам хозяин удален, потому что именно тогда менеджер памяти зафиксирует выход ссылки из видимости. И если зависимый объект решит обратиться к хозяину, то обычно вылезает AV.

Бороться с этим можно. Самый лучший вариант: сделать так, чтобы зависимым не приходилось обращаться к хозяину при удалении. Если не получается, тогда слегка забыть про автоматизм и вручную обнулить все ссылки на зависисмые объекты еще внутри деструктора хозяина, когда он жив.

Проблема 2. "Проблема 1", вообще говоря, случается только тогда, когда хозяин имеет интерфейсные ссылки на зависимые объекты, а зависимые имеют неинтерфейсную, самую обычную ссылку на объект хозяина. Если же зависимые имеют интерфейсную ссылку на хозяина, то вы получаете замкнутый круг: никто из них не сможет умереть первым. Даже если вы выкинете последнюю внешнюю ссылку на хозяина, то он будет висеть в памяти за счет ссылок со своих зависимых, и вы получите утечку памяти: объектов никто не знает, кроме них самих, и даже принудительно их удалить некому. Поэтому хранения интерфейсных ссылок на хозяина лучше избегать.

Проблема 3. Если вы создаете объект, у которого есть интерфейсы, и хотите, чтобы им кто-то таки владел, то хозяин должен ссылаться на зависимый объект через интерфейсную ссылку. Если этого не делать, то первый же факт использования зависимого объекта кем-то другим тут же его и убьет:

TReport=Class(TInterfacedObject,IReport)
  //объект TReport реально реализует интерфейс IReport
End;{TReport}

THost=Class
Private
  Report:TReport; //неинтерфейсная ссылка
Public
  Constructor Create;
  Function GetReport:IReport;
End;{THost}
...
Constructor THost.Create;
begin
  Report:=TReport.Create; //нет никаких AddRef'ов, так как Report - неинтерфейсная ссылка
end;


Function GetReport:IReport;
begin
  Result:=Report; //AddRef реально делается здесь, потому что Result как раз интерфейсная ссылка IReport
end;

//Теперь казалось бы безобидный код:

Host.GetReport.Preview;
Host.GetReport.Print; //все с треском падает

Почему падает? Потому что перед вызовом Host.GetReport.Preview у объекта Report счетчик ссылок был равен нулю: он был только создан в THost.Create и там никакого начального AddRef'а не было. Теперь, как только происходит первый Host.GetReport для возвращаемой ссылки IReport делается AddRef, счетчик становится равным 1. Но тут же эта переменная пропадает из области видимости: результат функции не был ничему присвоен, а был использован тут же в операторе для вызова Preview. Раз дальше он не существует, вызывается Release. Счетчик падает на нуль, объект уничтожается. Любое следующее обращение к нему - AV.

Вот поэтому для того, чтобы объект с интерфейсами хранился долго, хозяин должен иметь на него интерфейсную ссылку, которая сделает этот самый первый AddRef, позволяющий просуществовать объекту до смерти хозяина.

Проблема 4. Маленькая. Скорее — напоминание. Ошибку в деструкторе объекта с интерфейсами трудно поймать в дебаггере. Ведь Release вызывается неявно, а при последнем Release из него вызывается деструктор. И если идти по отладчику по шагам, то выглядит это так, что у вас отработали все ваши деструкторы, и вдруг на последнем "операторе" end вылезает ошибка. Когда такое происходит, просто поставьте точки прерывания в деструкторы всех объектов, на который хозяин имеет интерфейсные ссылки.

Комментарии: 4

  1. Alena

    В начале статьи как-то ненавязчиво внушается мысль, что не автоматическое освобождения памяти уже вроде как никому и не нужно. Вот все-таки хочу сказать, что есть довольно много программ, где производительность выходит на первый план и тормоза автоматической сборки мусора становятся очень существенными. Драйвера, игры и т.п.

  2. Yaroslav

    Раньше я "тащился" от интерфейсов - теперь стараюсь отказаться от использования там где это возможно, причины:
    * вызов интерфейсного метода медленнее вызова метода обычного объекта
    * каждый раз мы вынуждены описывать интерфейс, а затем его реализацию в классе, даже если интерфейс используется один раз только в нем.
    * невозможно контролировать _AddRef и _Release, Delphi берет на себя весь подсчет ссылок, даже когда это не нужно. Это удается "исправить" присвением указателя на интерфейс к Pointer-у, что не добавляет ясности коду.
    А автоматический подсчет ссылок можно сделать и для обычных классов - что я сейчас и практикую.

  3. patrick

    Ага особенно игры :) . Куда приятнее играть в игру 10 минут, а потом погибая в тормазах вылетать в винду из-за того, что программеры не исправили много ошибок работы с памятью...

    И куда приятнее играть в игру, кторая тормозит стабильно, если конечно тормозит не сильно. Говорю про Хром, который тормозил немного на моей старой, слабинькой системе, на новой такого не наблюдается. Хочу заметить, что хром накатан на Java.

  4. Александр Завальный

    Прикольная статья, прочитать было полезно, НО!
    1. Хороший программист не будет плакать из-за Free, в конце концов это переходит в привычку.
    2. Как уже некто здесь писал, интерфейсы это долго, особенно когда это происходит в циклах и часто.
    3. Если программа не должна работать без перерыва, можно забить на освобождение памяти (мне, и многим другим, приходится писать извращенные программы которые должны работать неделями без перезагрузки, так что увы).
    4.Все хороом переходим на .NET и весело без геморроя тормозим.
    все.

Добавить комментарий