Compartir a través de


Windows con C++

Revisión de los punteros inteligentes de COM

Kenny Kerr

Kenny KerrDespués de la segunda versión de COM, conocida también como Windows en tiempo de ejecución, la necesidad de un puntero inteligente eficaz y confiable para las interfaces COM es más importante que nunca. Pero, ¿qué hace que un puntero inteligente de COM sea bueno? La plantilla de clase CComPtr de ATL ha sido el puntero inteligente de COM de facto durante décadas. El SDK de Windows para Windows 8 introdujo la plantilla de clase ComPtr como parte de la biblioteca de plantillas de C++ de Windows en tiempo de ejecución (WRL), que algunos consideraban como un sustituto moderno de CComPtr de ATL. Al principio, también pensé que esto era un gran paso hacia delante, pero después de mucha experiencia en el uso de ComPtr de WRL, he llegado a la conclusión de que debe evitarse. ¿Por qué no? Siga leyendo.

¿Cómo se debe proceder? ¿Deberíamos volvemos a ATL? De ninguna manear, pero quizás es el momento de aplicar algo del C++ moderno que ofrece Visual C++ 2015 en el diseño de un nuevo puntero inteligente para las interfaces COM. En Connect(); el número especial de Visual Studio de 2015 y Microsoft Azure, mostré cómo aprovechar al máximo Visual C++ 2015 para implementar fácilmente IUnknown e IInspectable mediante la plantilla de clase Implements. Ahora mostraré cómo usar más de lo que ofrece Visual C++ 2015 para implementar una nueva plantilla de clase ComPtr.

Es considerablemente difícil escribir punteros inteligentes, pero gracias a C++11, no es ni remotamente tan difícil como antes. En parte tiene que ver con todos los ingeniosos trucos que los programadores de bibliotecas han diseñado para solucionar la falta de expresividad en el lenguaje C++ y las bibliotecas estándares, con el fin de hacer sus propios objetos actúen como punteros integrados mientras se mantienen eficaces y correctos. En concreto, las referencias de valor R consiguen que la vida sea mucho más fácil para nosotros, los desarrolladores de bibliotecas. Otra parte es simplemente retrospectiva, con una comprobación de cómo le ha ido a los diseños existentes. Y, por supuesto, está el dilema de todos los desarrolladores: mostrar la restricción y no intentar empaquetar cada característica imaginable en una abstracción determinada.

En el nivel más básico, un puntero inteligente de COM debe proporcionar una administración de recursos para el puntero de interfaz de COM subyacente. Esto implica que el puntero inteligente será una plantilla de clase y almacenará un puntero de interfaz del tipo deseado. Técnicamente, en realidad no necesita almacenar un puntero de interfaz de un tipo determinado, sino que en su lugar puede almacenar solo un puntero de interfaz IUnknown, pero, a continuación, el puntero inteligente tendría que depender de static_cast siempre que se desreferencia el puntero inteligente. Esto puede ser útil y conceptualmente peligroso, pero hablaré sobre él en una columna futura. Por ahora, comenzaré con una plantilla de clase básica para almacenar un puntero fuertemente tipado:

template <typename Interface>
class ComPtr
{
public:
  ComPtr() noexcept = default;
private:
  Interface * m_ptr = nullptr;
};

Los desarrolladores de C++ más veteranos podrían preguntarse en un principio de qué trata todo esto, pero lo más probable es que los desarrolladores de C++ más activos no se sorprendan demasiado. La variable miembro m_ptr se basa en una magnífica característica nueva que permite que los miembros de datos no estáticos se inicialicen donde se declaran. Esto reduce considerablemente el riesgo de que accidentalmente se olvide inicializar las variables miembro como constructores que se agregan y cambian con el tiempo. Cualquier inicialización que proporcione explícitamente un constructor concreto tiene prioridad sobre esta inicialización in situ, pero para la mayor parte esto significa que los constructores no tienen que preocuparse acerca de cómo establecer estas variables de miembro, que de lo contrario se habrían iniciado con valores imprevisibles.

Puesto que ahora se garantiza que el punto de interfaz está inicializado, también puedo confiar en otra característica nueva que solicita explícitamente una definición predeterminada de funciones miembro especiales. En el ejemplo anterior, se solicita la definición predeterminada del constructor predeterminado (un constructor predeterminado predeterminado, si desea verlo así). No culpe al mensajero. Aun así, la capacidad para establecer como predeterminadas o eliminar funciones miembro especiales, junto con la capacidad de inicializar las variables miembro en el momento de la declaración se encuentran entre mis características favoritas que ofrece Visual C++ 2015. Son esos pequeños detalles que importan.

El servicio más importante que debe ofrecer un puntero inteligente de COM es el de proteger al desarrollador de los peligros del modelo intrusivo de recuento de referencias de COM. En realidad, me gusta el enfoque de COM con respecto al recuento de referencias, pero quiero una biblioteca que se encargue de él por mí. Esto se manifiesta en varios lugares sutiles a lo largo de la plantilla de clase ComPtr, pero quizás el más evidente es cuando un llamador desreferencia el puntero inteligente. No deseo que un llamador escriba algo como lo siguiente, por accidente o de otro modo:

ComPtr<IHen> hen;
hen->AddRef();

La capacidad de llamar a las funciones virtuales AddRef o Release debería ser responsabilidad exclusiva del puntero inteligente. Por supuesto, el puntero inteligente debe permitir que se llame a los métodos restantes a través de una operación de desreferencia de este tipo. Normalmente, un operador de desreferencia de un puntero inteligente podría parecerse a lo siguiente:

Interface * operator->() const noexcept
{
  return m_ptr;
}

Esto funciona para los punteros de interfaz de COM y no es necesario para una aserción, ya que una infracción de acceso es más instructiva. Pero esta implementación permitirá que un llamador llame a AddRef y Release. La solución es devolver simplemente un tipo que prohíba que se llame a AddRef y Release. Una plantilla de clase pequeña resulta útil:

template <typename Interface>
class RemoveAddRefRelease : public Interface
{
  ULONG __stdcall AddRef();
  ULONG __stdcall Release();
};

La plantilla de clase RemoveAddRefRelease hereda todos los métodos del argumento de plantilla, pero declara como privados AddRef y Release para que el llamador no haga referencia a esos métodos de forma accidental. El operador de desreferencia del puntero inteligente puede simplemente usar static_cast para proteger el puntero de interfaz devuelto:

RemoveAddRefRelease<Interface> * operator->() const noexcept
{
  return static_cast<RemoveAddRefRelease<Interface> *>(m_ptr);
}

Esto es tan solo un ejemplo donde mi ComPtr se desvía del enfoque de WRL. WRL opta por hacer privados todos los métodos de IUnknown, incluido QueryInterface, y no veo ningún motivo para restringir los llamadores de esa manera. Significa que WRL debe inevitablemente proporcionar alternativas a este servicio esencial y que agrega una mayor complejidad y confusión para los llamadores.

Dado que mi ComPtr hace un claro uso del recuento de referencias, es crucial que lo haga correctamente. Bueno, comenzaré con un par de funciones auxiliares privadas, empezando con una para AddRef:

void InternalAddRef() const noexcept
{
  if (m_ptr)
  {
    m_ptr->AddRef();
  }
}

Esto no es realmente apasionante, pero hay una gran variedad de funciones que requieren que se tome una referencia de forma condicional y así se garantiza que hagan lo correcto en todo momento. La función auxiliar correspondiente para Release es un poco más sutil:

void InternalRelease() noexcept
{
  Interface * temp = m_ptr;
  if (temp)
  {
    m_ptr = nullptr;
    temp->Release();
  }
}

¿Por qué temporal? Bueno, consideremos la implementación más intuitiva, pero incorrecta, que refleja aproximadamente lo que he hecho (correctamente) dentro de la función InternalAddRef:

if (m_ptr)
{
  m_ptr->Release(); // BUG!
  m_ptr = nullptr;
}

El problema es que al llamar al método Release, se puede activar una cadena de eventos que podría ocasionar que el objeto se libere una segunda vez. Este segundo paso a través de InternalRelease encontraría de nuevo un puntero de interfaz no nulo e intentaría liberarlo de nuevo. Hay que reconocer que se trata de un caso poco común, pero el trabajo del desarrollador de biblioteca implica tener en cuenta esos aspectos. La implementación original que incluye un temporary evita este doble Release al separar primero el puntero de interfaz desde el puntero inteligente y solo llamar a Release después. Si nos remontamos atrás en el tiempo, parece que Jim Springfield fue el primero en detectar este error desconcertante en ATL. En cualquier caso, con estas dos funciones auxiliares a mano, puedo comenzar a implementar algunas de las funciones miembro especiales que ayudan a hacer que el objeto resultante parezca actúe como un objeto integrado. El constructor de copias es un ejemplo sencillo.

A diferencia de los punteros inteligentes que proporcionan propiedad exclusiva, se debe permitir la construcción de copias de los punteros inteligentes de COM. Se debe tener cuidado para evitar las copias a toda costa, pero si un llamador realmente desea una copia, lo que obtendrá será una copia. Aquí se muestra un constructor de copias simple:

ComPtr(ComPtr const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

Esto se ocupa del caso obvio de construcción de copias. Copia el puntero de interfaz antes de llamar a la aplicación auxiliar InternalAddRef. Si lo dejara aquí, la copia de un ComPtr parecería un puntero integrado, pero no es complemente así. Por ejemplo, podría crear una copia similar a la siguiente:

ComPtr<IHen> hen;
ComPtr<IHen> another = hen;

Esto refleja lo que puedo hacer con punteros sin formato:

IHen * hen = nullptr;
IHen * another = hen;

Pero los punteros sin formato también permiten esto:

IUnknown * unknown = hen;

Con mi constructor de copias simple, no se me permite hacer lo mismo con ComPtr:

ComPtr<IUnknown> unknown = hen;

Aunque IHen debe derivar en última instancia de IUnknown, ComPtr<IHen> no deriva de ComPtr<IUnknown> y el compilador los considera tipos no relacionados. Lo que necesito es un constructor que actúe como un constructor de copias lógicas para otros objetos ComPtr relacionados de manera lógica; en concreto, cualquier ComPtr con un argumento de plantilla que se pueda convertir al argumento de plantilla del ComPtr construido. Aquí, WRL depende de type traits, pero esto no es realmente necesario. Todo lo que necesito es una plantilla de función para ofrecer la posibilidad de conversión y, entonces, dejaré simplemente que el compilador compruebe si realmente se puede convertir:

template <typename T>
ComPtr(ComPtr<T> const & other) noexcept :
  m_ptr(other.m_ptr)
{
  InternalAddRef();
}

En el momento en que el otro puntero se usa para inicializar el puntero de interfaz del objeto, el compilador comprueba si la copia es significativa. Así, esto compilará:

ComPtr<IHen> hen;
ComPtr<IUnknown> unknown = hen;

Pero esto no:

ComPtr<IUnknown> unknown;
ComPtr<IHen> hen = unknown;

Y así es como debería ser. Por supuesto, el compilador aún considera que los dos son tipos muy diferentes, por lo que la plantilla de constructor realmente no tendrá acceso a la otra variable miembro privada, a menos que las haga amigas:

template <typename T>
friend class ComPtr;

Podría verse tentado a quitar parte del código redundante porque IHen se puede convertir a IHen. ¿Y por qué no se elimina simplemente el constructor de copias real? El problema es que el compilador no considera este segundo constructor como un constructor de copias. Si omite el constructor de copias, el compilador supondrá que quería eliminarlo y los objetos de cualquier referencia a esta función eliminada. Adelante.

Una vez se ha tratado la construcción de copias, es muy importante que ComPtr también proporcione la construcción de movimiento. Si se permite el movimiento en un caso determinado, ComPtr debería permitir que el compilador opte a ello, ya que almacenará un cambio de referencias, que es mucho más costoso que una operación de movimiento. Un constructor de movimiento es incluso más sencillo que el constructor de copias, ya que no hay necesidad de llamar a InternalAddRef:

ComPtr(ComPtr && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

Copia el puntero de interfaz antes de borrar o restablecer el puntero en la referencia de valor R o el objeto desde el que se va a mover. Sin embargo, en este caso, el compilador no es tan delicado y simplemente puede ahorrarse este constructor de movimiento para una versión genérica que admite tipos convertibles:

template <typename T>
ComPtr(ComPtr<T> && other) noexcept :
  m_ptr(other.m_ptr)
{
  other.m_ptr = nullptr;
}

Y con esto se concluyen los constructores ComPtr. El destructor es predecible y simple:

~ComPtr() noexcept
{
  InternalRelease();
}

Ya he tenido en cuenta los matices de destrucción dentro de la aplicación auxiliar InternalRelease, así que aquí puedo simplemente volver a usar esa maravilla. Hemos tratado la construcción de copias y movimiento, pero los operadores de asignación correspondientes también se deben proporcionar a este puntero inteligente para que parezca un puntero real. Para admitir esas operaciones, agregaré otro par de funciones auxiliares privadas. La primera es para la adquisición de forma segura de una copia de un puntero de interfaz determinado:

void InternalCopy(Interface * other) noexcept
{
  if (m_ptr != other)
  {
    InternalRelease();
    m_ptr = other;
    InternalAddRef();
  }
}

Suponiendo que los punteros de interfaz no sean iguales (o que ambos no sean punteros nulos), la función libera cualquier referencia existente antes de realizar una copia del puntero y asegurar una referencia al nuevo puntero de interfaz. De esta forma, puedo llamar fácilmente a InternalCopy para tomar posesión de una referencia única a la interfaz especificada, incluso aunque el puntero inteligente ya contenga una referencia. De forma similar, la segunda aplicación auxiliar se ocupa del movimiento seguro de un puntero de interfaz determinado, junto con el recuento de referencias que representa:

template <typename T>
void InternalMove(ComPtr<T> & other) noexcept
{
  if (m_ptr != other.m_ptr)
  {
    InternalRelease();
    m_ptr = other.m_ptr;
    other.m_ptr = nullptr;
  }
}

Aunque InternalCopy admite naturalmente tipos convertibles, esta función es una plantilla para proporcionar esta capacidad para la plantilla de clase. Por otra parte, InternalMove es prácticamente igual, pero mueve lógicamente el puntero de interfaz, en lugar de adquirir una referencia adicional. Tras quitar eso, puedo implementar los operadores de asignación de forma bastante simple. Primero, la asignación de copia y, como con el constructor de copias, debo proporcionar la forma canónica:

ComPtr & operator=(ComPtr const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

A continuación, puedo proporcionar una plantilla para tipos convertibles:

template <typename T>
ComPtr & operator=(ComPtr<T> const & other) noexcept
{
  InternalCopy(other.m_ptr);
  return *this;
}

Pero, al igual que el constructor de movimiento, puedo sencillamente proporcionar una única versión genérica de asignación de movimiento:

template <typename T>
ComPtr & operator=(ComPtr<T> && other) noexcept
{
  InternalMove(other);
  return *this;
}

Aunque la semántica de movimiento es a menudo superior a la de copia cuando se trata de punteros inteligentes con recuento de referencias, los movimientos implican un coste y una manera excelente de evitar movimientos en algunos escenarios claves es ofrecer la semántica de intercambio. Muchos tipos de contenedor darán prioridad a las operaciones de intercambio por encima de los movimientos, lo que puede evitar la construcción de una carga enorme de objetos temporales. La implementación de la funcionalidad de intercambio de ComPtr es bastante sencilla:

void Swap(ComPtr & other) noexcept
{
  Interface * temp = m_ptr;
  m_ptr = other.m_ptr;
  other.m_ptr = temp;
}

Usaría el algoritmo de intercambio estándar, pero, al menos en la implementación de Visual C++, el encabezado <utilidad> obligatorio incluye también indirectamente <stdio.h> y realmente no deseo obligar a que los desarrolladores incluyan todo eso solo para el intercambio. Por supuesto, para que los algoritmos genéricos encuentren mi método Swap, es necesario proporcionar también una función de intercambio que no sea miembro (en minúsculas):

template <typename Interface>
void swap(ComPtr<Interface> & left, 
  ComPtr<Interface> & right) noexcept
{
  left.Swap(right);
}

Siempre y cuando se defina en el mismo espacio de nombres que la plantilla de clase ComPtr, el compilador permitirá que los algoritmos genéricos hagan uso del intercambio.

Otra característica interesante de C++11 son los operadores de conversión explícitos. Históricamente, fue necesario realizar algunas modificaciones complicadas para conseguir un operador booleano explícito confiable que comprobase si un puntero inteligente era lógicamente no nulo. Hoy en día, es tan sencillo como lo siguiente:

explicit operator bool() const noexcept
{
  return nullptr != m_ptr;
}

Y eso se ocupa de los miembros especiales y prácticamente especiales que hacen que mi puntero inteligente se comporte como un tipo integrado con tanta asistencia como puedo ofrecer, para ayudar a que el compilador optimice y prevenga cualquier sobrecarga. Lo que falta es una pequeña selección de aplicaciones auxiliares que son necesarios para las aplicaciones de COM en muchos casos. En este punto se debe tener cuidado para evitar agregar demasiados adornos. Aun así, hay una serie de funciones de las que dependerá casi cualquier componente o aplicación no triviales. En primer lugar, debe disponer de una forma para liberar explícitamente la referencia subyacente. Eso es bastante fácil:

void Reset() noexcept
{
  InternalRelease();
}

A continuación, necesita tener una forma de obtener el puntero subyacente, si el llamador debe pasarlo como argumento a alguna otra función:

Interface * Get() const noexcept
{
  return m_ptr;
}

Probablemente necesite separar la referencia, quizás para devolverlo al llamador:

Interface * Detach() noexcept
{
  Interface * temp = m_ptr;
  m_ptr = nullptr;
  return temp;
}

Puede ser necesario realizar una copia de un puntero existente. Podría ser una referencia que retuvo el llamador y que me gustaría mantener:

void Copy(Interface * other) noexcept
{
  InternalCopy(other);
}

O bien, puede que tenga un puntero sin formato que posea una referencia a su destino que me gustaría adjuntar, sin que se adquiera una referencia adicional. Esto también puede ser útil para las referencias de fusión en casos excepcionales:

void Attach(Interface * other) noexcept
{
  InternalRelease();
  m_ptr = other;
}

Las últimas funciones desempeñan un papel de especial importancia, por lo que les dedicaré algo más de atención. Tradicionalmente, los métodos COM devuelven referencias como parámetros de salida mediante un puntero a un puntero. Es importante que cualquier puntero inteligente COM ofrezca una manera de capturar directamente esas referencias. Para ello, proporciono el método GetAddressOf:

Interface ** GetAddressOf() noexcept
{
  ASSERT(m_ptr == nullptr);
  return &m_ptr;
}

De nuevo, en este punto mi ComPtr se desvía de la implementación de WRL de manera sutil, pero muy importante. Observe que GetAddressOf impone que no se mantenga una referencia antes de devolver su dirección. Esto es de vital importancia, ya que de lo contrario la función a la que se llama simplemente sobrescribirá cualquier referencia que se haya mantenido y se producirá una pérdida de referencia. Sin la aserción, estos errores son mucho más difíciles de detectar. En el otro extremo del espectro se encuentra la capacidad de distribuir referencias, ya sean del mismo tipo o de otras interfaces que el objeto subyacente podría implementar. Si se desea otra referencia a la misma interfaz, puedo evitar llamar a QueryInterface y simplemente devolver una referencia adicional con la convención que indica COM:

void CopyTo(Interface ** other) const noexcept
{
  InternalAddRef();
  *other = m_ptr;
}

Y se puede usar como sigue:

hen.CopyTo(copy.GetAddressOf());

En otro caso, se puede utilizar la propia QueryInterface sin ninguna otra ayuda de ComPtr:

HRESULT hr = hen->QueryInterface(other.GetAddressOf());

En realidad se basa en una plantilla de función que proporciona directamente IUnknown, para evitar tener que proporcionar de forma explícita el GUID de la interfaz.

Por último, a menudo hay casos en que una aplicación o un componente necesitan consultar una interfaz sin pasarla necesariamente de vuelta al llamador en la convención clásica de COM. En esos casos, tiene más sentido devolver este nuevo puntero de interfaz perfectamente colocado dentro de otro ComPtr, como se indica a continuación:

template <typename T>
ComPtr<T> As() const noexcept
{
  ComPtr<T> temp;
  m_ptr->QueryInterface(temp.GetAddressOf());
  return temp;
}

A continuación, sencillamente puedo usar el operador booleano explícito para comprobar si la consulta se ha realizado correctamente. Por último, ComPtr también proporciona todos los operadores de comparación de no miembros esperados por comodidad y para admitir varios contenedores y algoritmos genéricos. De nuevo, esto solo ayuda a hacer que el puntero inteligente se parezca más y actúe como un puntero integrado, mientras se proporcionan los servicios esenciales para administrar adecuadamente los recursos y proporcionar los servicios necesarios que los componentes y las aplicaciones COM esperan. La plantilla de clase ComPtr es solo un ejemplo del uso de C++ moderno para Windows en tiempo de ejecución (consulte moderncpp.com).


Kenny Kerr es programador informático radicado en Canadá, además de autor para Pluralsight y MVP de Microsoft. Tiene un blog en kennykerr.ca y puede seguirlo en Twitter en twitter.com/kennykerr.

Gracias al siguiente experto técnico de Microsoft por revisar este artículo: James McNellis