Указатели и память
Интерфейсы, показанные в данной главе до настоящего момента, были довольно просты и использовали только примитивные типы данных. При применении сложных типов данных одной из наиболее серьезных проблем является управление памятью для параметров метода. Рассмотрим следующий прототип функции IDL:
HRESULT f([out] short *ps);
При наличии такого прототипа нижеследующий код вполне допустим с точки зрения С:
short s; HRESULT hr = p->f(&s); // s now contains whatever f wrote // s теперь содержит все, что написал f
Должно быть очевидно, как организована память для такой простой функции. Однако часто начинающие (и не только начинающие) программисты по ошибке пишут код, подобный следующему:
short *ps; // the function says it takes a short *, so ... // функция говорит, что она берет * типа short, следовательно ... HRESULT hr = p->f(ps);
При рассмотрении следующей допустимой реализации функции:
STDMETHODIMP MyClass::f(short *ps) { static short n = 0; *ps = n++; return S_OK; }
очевидно, что выделение памяти для короткого целого числа и передача ссылки на память в качестве аргумента функции является обязанностью вызывающей программы. О только что приведенной реализации заметим, что для функции неважно, откуда взялась эта память (например, динамически выделена из "кучи", объявлена как переменная auto в стеке), до тех пор, пока текущий аргумент ссылается на допустимую область памяти. Для подкрепления этого положения СОМ требует, чтобы все параметры с атрибутами [out], являющиеся указателями, были ссылочными указателями.
Ситуация становится менее очевидной, когда вместо простых целых типов используются типы, определенные пользователем. Рассмотрим следующее IDL-определение:
typedef struct tagPoint { short x; short у; } Point;
HRESULT g([out] Point *pPoint);
Как и в предыдущем примере, правильной является такая схема: вызывающая программа выделяет память для значений и передает ссылку на память, выделенную вызывающей программой:
Point pt; HRESULT hr = p->g(&pt);
Если вызывающая программа передала неверный указатель:
Point *ppt; // random unitialized pointer // случайный неинициализированный указатель HRESULT hr = p->g(ppt); // where should proxy copy x & у to? // куда заместитель должен копировать x и у ?
то не найдется легальной памяти, куда метод (или интерфейсный заместитель) мог бы записать значения x и y.
Чем более сложные типы определяются пользователем, тем интереснее становится сценарий. Рассмотрим следующий код IDL:
[uuid(E02E5345-l473-11d1-8C85-0080C73925BA),object ] interface IDogManager : IUnknown { typedef struct tagHUMAN { long nHumanID; } HUMAN;
typedef struct tagDOG { long nDogID; [unique] HUMAN *pOwner; } DOG;
HRESULT GetFromPound([out] DOG *pDog); HRESULT TakeToGroomer([in] const DOG *pDog); HRESULT SendToVet([in, out] DOG *pDog); }
Отличительная особенность этого интерфейса состоит в том, что теперь вызывающая программа должна передать указатель на такой участок памяти, который уже содержит указатель. Можно показать, что для приведенного выше определения метода следующий код является правильным:
DOG fido; // argument is a DOG *, so caller needs a DOG // аргументом является DOG *, поэтому вызывающей программе нужен DOG HUMAN dummy; // the DOG refers to an owner, so alloc space? // DOG ссылается на владельца, поэтому выделяем память? fido.pOwner = &dummy; HRESULT hr = p->GetFromPound(&fido); // is this correct? // правильно ли это?
В данном коде предполагается, что вызывающая программа ответственна за выделение памяти для DOG, который передается по ссылке. В этом смысле код правилен. Однако в этом коде также предполагается, что он отвечает за управление любой памятью более низкого уровня, на которую могут сослаться обновленные значения объекта DOG. Именно здесь данный код отступает от правил СОМ.
СОМ разделяет указатели, участвующие в вызове метода, на две категории. Любые именованные параметры метода, являющиеся указателями, относятся к указателям высшего уровня (top-level).
Любой подчиненный указатель, который получен путем разыменования указателя высшего уровня, является вложенным (embedded) указателем. В методе GetFromPound параметр pDog считается указателем высшего уровня. Подчиненный указатель pDog->pOwner рассматривается как вложенный указатель. Отметим, что определение структуры DOG использует атрибут [unique] для явной квалификации семантики указателя для элемента структуры pOwner. Если бы семантика указателя не была квалифицирована явно, разработчик интерфейса мог бы применить принятый по умолчанию во всех интерфейсах для всех вложенных указателей атрибут [pointer_default]:
[ uuid(E02E5345-1473-11d1-8C85-0080C73925BA), object, pointer_default(ref) // default embedded ptrs to [ref] // по умолчанию вложенные указатели [ref] ] interface IUseStructs : IUnknown { typedef struct tagNODE { long val; [unique] struct tagNODE *pNode; // explicitly [unique] // явно [unique] } NODE;
typedef struct tagFOO { long val; long *pVal; // implicitly [ref] // неявно [ref] } FOO;
HRESULT Method([in] FOO *pFoo, [in, unique] NODE *pHead); }
Атрибут [pointer_default] применяется только к тем вложенным указателям, семантика которых не квалифицирована явно. В приведенном выше определении интерфейса единственный указатель, к которому это относится, — это элемент данных pVal структуры FOO. Элемент pNode структуры NODE явно квалифицирован как уникальный указатель, поэтому установка [pointer_default] на него не влияет. На параметры метода pFoo и pHead атрибут [pointer_default] также не влияет, поскольку они являются указателями высшего уровня и по умолчанию [ref], если только они не квалифицированы явно иным образом (как в случае с pHead).
Основная причина, по которой вложенные указатели имеют в СОМ отдельный статус, заключается в том, что они предъявляют особые требования к организации памяти. Для параметров с атрибутом [in] различие между указателями высшего уровня и вложенными указателями не слишком существенно, так как вызывающая программа обеспечивает метод всеми значениями и поэтому должна заранее выделить память, которую эти значения будут занимать:
HUMAN bob = { 2231 }; DOG fido = { 12288, &bob }; // fido is owned by bob // fido принадлежит bob'y HRESULT hr = p->TakeToGroomer(&fido); // this is correct! // это правильно!
В то же время разграничение между указателями высшего уровня и вложенными является существенным, когда оно касается организации памяти для параметров [out] и [in,out]. Для обоих параметров, [out] и [in,out], память, на которую ссылаются указатели высшего уровня, управляется вызывающим оператором, как и в случае параметров [in]. Для вложенных же указателей, которые появляются в параметрах [out] и [in, out], память управляется вызываемым оператором (самим методом). Причина появления этого правила заключается в том, что глубина вложения типов данных может быть сколь угодно большой. Например, в таком определении типа:
typedef struct tagNODE { short value; [unique] struct tagNODE *pNext; } NODE:
вызывающему оператору невозможно заранее определить, сколько подэлементов понадобится разместить. Однако, поскольку вызываемый оператор (данный метод) будет снабжать данными каждый узел, он благополучно может выделить память для каждого необходимого узла.
При наличии правила, по которому разработчики метода должны выделять память при инициализации любых вложенных указателей, возникает естественный вопрос, откуда методы должны получить эту память, чтобы вызывающие операторы знали, как освободить ее после прочтения всех возвращенных значений? Ответом является распределитель памяти (task allocator) СОМ-задачи. Распределителем памяти задачи СОМ называется распределитель памяти, индивидуальный для каждого процесса, используемый исключительно для выделения памяти вложенным указателям с атрибутами [out] и [in,out]. Проще всего использовать этот распределитель памяти СОМ-задачи посредством применения трех API-функций СОМ:
void *CoTaskMemAlloc(DWORD cb); // allocate cb bytes // размещаем cb байтов void CoTaskMemFree(void *pv); // deallocate memory at *pv // освобождаем память в *pv void *CoTaskMemRealloc(void *pv,DWORD cb); // grow/shrink *pv // расширяем/сжимаем *pv
Семантика этих трех функций такая же, как у их эквивалентов из динамической библиотеки С: malloc, free и realloc. Разница состоит в том, что они предназначены исключительно для выделения памяти параметрам типа вложенных указателей с атрибутами [out] и [in,out]. Другое важное отличие состоит в том, что подпрограммы из динамической библиотеки С нельзя использовать для выделения памяти в одном модуле и освобождения ее в другом. Дело в том, что детали реализации каждой динамической библиотеки С являются специфическими и изменяются при смене компилятора. Так как все участники согласились использовать один и тот же распределитель, предлагаемый СОМ, нет проблемы с освобождением клиентом памяти, которая выделена объектом, скомпилированным в отдельной DLL.
Чтобы понять, как используются на практике блоки памяти, выделенные вызываемым оператором, рассмотрим приводившийся ранее метод GetFromPound:
HRESULT GetFromPound([out] DOG *pDog);
В то время как память для объекта DOG должна быть выделена вызывающей программой (pDog является указателем высшего уровня), память для объекта HUMAN должна быть выделена реализацией метода с использованием распределителя памяти задачи (pDog->pOwner является вложенным в [out]-параметр указателем). Реализация метода выглядела бы примерно так:
STDMETHODIMP GetFromPound(/*[out]*/DOG *pDog) { short did = LookupNewDogId(); short hid = LookupHumanId(did); pDog->nDogID = did; // allocate memory for embedded pointer // выделяем память для вложенного указателя pDog->pOwner = (HUMAN*) CoTaskMemAlloc(sizeof(HUMAN)); if (pDog->pOwner == 0) // not enough memory // недостаточно памяти return R_OUTOFMEMORY; pDog->pOwner->nHumanID = hid; return S_OK; }
Отметим, что метод возвращает специальный HRESULT E_OUTOFMEMORY, указывающий на то, что операция прервана из-за нехватки памяти.
Программа, вызывающая метод GetFromPound, ответственна за освобождение любой памяти, выделенной вызываемым методом, после использования соответствующих значений:
DOG fido; HRESULT hr = p->GetFromPound(&fido); if (SUCCEEDED(hr)) { printf("The dog %h is owned by %h", fido.nDogID, fido.pOwner->nHumanID); // data has been consumed, so free the memory // данные использованы, поэтому освобождаем память CoTaskMemFree(fido.pOwner); }
В случае сбоя метода клиент может предположить, что не было выделено никакой памяти, если только в документации не указан другой исход.
В только что приведенном примере использован чистый [out]-параметр. Управление [in, out]- параметрами несколько более сложно. Вложенные указатели для [in, out]-параметров должны быть размещены вызывающей программой с помощью распределителя памяти задачи. Если методу требуется повторно распределить память, переданную клиентом, то метод должен сделать это с использованием CoTaskMemRealloc. Если же вызывающая программа не имеет никакой информации для передачи методу, то она может передать ему на входе нулевой указатель, и тогда метод может использовать CoTaskMemRealloc (который без проблем принимает нулевой указатель и делает то, что нужно). Подобным же образом, если у метода нет информации для обратной передачи в вызывающую программу, он может просто освободить память, на которую ссылается вложенный указатель. Рассмотрим следующее определение метода IDL:
HRESULT SendToVet([in, out] DOG *pDog);
Пусть у вызывающей программы имеется легальное значение HUMAN, которое она хочет передать как параметр. Тогда клиентский код может выглядеть примерно так:
HUMAN *pHuman = (HUMAN*)CoTaskMemAllocc(sizeof(HUMAN)); pHuman->nHumanID = 1522; DOG fido = { 4111, pHuman }; HRESULT hr = p->SendToVet(&fido); // [in, out] if (SUCCEEDED(hr)) { if (fido.pOwner) printf("Dog is now owned by %h", fido.pOwner->nHumanID); CoTaskMemFree(fido.pOwner); // OK to free null ptr. // можно освободить нулевой указатель }
Реализация метода могла бы повторно использовать буфер, используемый вызывающей программой, или выделить новый буфер в случае, если вызывающая программа передала нулевой вложенный указатель:
STDMETHODIMP MyClass::SendToVet(/*[in, out]*/DOG *pDog) { if (fido.pOwner == 0) fido.pOwner = (HUMAN*)CoTaskMemAlloc(sizeof (HUMAN)); if (fido.pOwner == 0) // alloc failed // сбой выделения памяти return E_OUTOFMEMORY; fido.pOwner->nHumanID = 22; return S_OK; }
Поскольку работа с [in,out]-параметрами в качестве вложенных указателей имеет ряд тонкостей, в документации на интерфейс часто повторяются правила управления памятью для вложенных указателей.
Приведенные выше фрагменты кода используют наиболее удобный интерфейс для СОМ-распределителя памяти задач. До появления версии СОМ под Windows NT основная связь с распределителем памяти задачи осуществлялась через его интерфейс IMallос:
[ uuid(00000002-0000-0000-C000-000000000046),local,object] interface IMalloc : IUnknown { void *Alloc([in] ULONG cb); void *Realloc ([in, unique] void *pv, [in] ULONG cb); void Free([in, unique] void *pv); ULONG GetSize([in, unique] void *pv); int DidAlloc([in, unique] void *pv); void HeapMinimize(void); }
Для получения доступа к интерфейсу IMalloc распределителя памяти задачи в СОМ имеется API-функция CoGetMalloc:
HRESULT CoGetMalloc( [in] DWORD dwMemCtx, // reserved, must be one // зарезервировано, должно равняться единице [out] IMalloc **ppMalloc); // put it here! // помещаем его здесь!
Это означает, что вместо вызова удобного метода CoTaskMemAlloc:
HUMAN *pHuman = (HUMAN*)CoTaskMemAlloc(sizeof(HUMAN));
можно использовать следующую менее удобную форму:
IMalloc *pMalloc = 0; pHuman = 0; HRESULT hr = CoGetMalloc(1, &pMalloc); if (SUCCEEDED(hr)) { pHuman = (HUMAN*)pMalloc->Alloc(sizeof(HUMAN)); pMalloc->Release(); }
Преимущество последней технологии заключается в том, что она совместима с ранними, до Windows NT, версиями СОМ. Но в целом предпочтительнее использовать CoTaskMemAlloc и другие, поскольку эти методы требуют меньше программного кода и поэтому меньше подвержены ошибкам программирования.
До сих пор обсуждение распределителя памяти задачи было сфокусировано на вопросах, как и когда объекты выделяют память, а клиенты — освобождают ее.
Однако не обсуждалось, что происходит, когда объект и клиент размещаются в различных адресных пространствах. Это во многом связано с отсутствием различия в способах реализации клиентов и объектов при использовании интерфейсных маршалеров. СОМ-распределитель памяти задачи получает свою память из закрытого адресного пространства процессов. С учетом этого сокрытие того обстоятельства, что распределитель памяти задачи не может охватить оба адресных пространства, является делом интерфейсной заглушки и интерфейсного заместителя. Когда интерфейсная заглушка вызывает метод объекта, она маршалирует любые [out]- или [in, out]-параметры в ответное ORPC-сообщение. Как показано на рис. 7.1, по завершении этого маршалинга интерфейсная заглушка (которая в конечном счете является внутриапартаментным клиентом данного объекта) освобождает с помощью метода CoTaskMemFree любую память, выделенную вызываемой программой. Это эффективно освобождает всю память, выделенную в течение вызова метода внутри адресного пространства объекта. При получении ответного ORPC-сообщения интерфейсный заместитель с помощью метода CoTaskMemAlloc выделяет пространство для всех параметров, размещаемых в вызываемой программе.
Когда эти блоки памяти освобождаются настоящим клиентом с помощью CoTaskMemFree, это эффективно освобождает всю память, выделенную в результате вызова метода, внутри адресного пространства клиента.
Поскольку программисты печально известны своим пренебрежением к освобождению памяти, иногда бывает полезно следить за активностью распределителя памяти задачи в процессе (или отсутствием таковой активности). Для обеспечения этого контроля СОМ предлагает подключить к распределителю памяти задачи определяемый пользователем шпионский объект (spy object), который будет уведомляться до и после каждого вызова распределителя памяти. Этот шпионский объект, определяемый пользователем, должен реализовать интерфейс IMallocSpy:
[ uuid(0000001d-0000-0000-C000-000000000046),local,object ] interface IMallocSpy : IUnknown { ULONG PreAlloc([in] ULONG cbRequest); void *PostAlloc([in] void *pActual); void *PreFree([in] void *pRequest,[in] BOOL fSpyed); void PostFree([in] BOOL fSpyed); ULONG PreRealloc([in] void *pRequest,[in] ULONG cbRequest, [out] void **ppNewRequest,[in] BOOL fSpyed); void *PostRealloc([in] void *pActual, [in] BOOL fSpyed); void *PreGetSize([in] void *pRequest, [in] BOOL fSpyed); ULONG PostGetSize([in] ULONG cbActual,[in] BOOL fSpyed); void *PreDidAlloc([in] void *pRequest, [in] BOOL fSpyed); int PostDidAlloc([in] void *pRequest, [in] BOOL fSpyed, [in] int fActual); void PreHeapMinimize(void); void PostHeapMinimize(void); }
Отметим, что для каждого метода IMalloc интерфейс IMallocSpy имеет два метода: один, вызываемый СОМ до того, как действующий распределитель памяти задачи начнет свою работу, и второй, вызываемый СОМ после того, как распределитель памяти выполнил свою работу. В каждом "предметоде" (premethod) предусмотренный пользователем шпионский объект может изменять параметры, передаваемые пользователем распределителю памяти. В каждом "постметоде" (postmethod) шпионский объект может изменять результаты, возвращаемые действующим распределителем памяти задачи. Это дает возможность шпионскому объекту выделять дополнительную память, чтобы добавить к каждому блоку памяти отладочную информацию. В СОМ имеется API-функция для регистрации шпиона распределения памяти (Malloc spy) всего процесса:
HRESULT CoRegisterMallocSpy([in] IMallocSpy *pms);
В каждом процессе может быть зарегистрирован только один шпион распределения памяти (CoRegisterMallocSpy возвратит CO_E_OBJISREG в том случае, если уже зарегистрирован другой шпион). Для удаления шпиона распределения в СОМ предусмотрена API-функция CoRevokeMallocSpy:
HRESULT CoRevokeMallocSpy(void);
СОМ не позволит отменить полномочия шпиона распределения до тех пор, пока не освобождена память, выделенная действующим шпионом.