Compartir a través de


Windows con C++

C++ y la API de Windows

Kenny Kerr

Kenny KerrLa API de Windows representa un desafío para el desarrollador de C++. Las diversas bibliotecas que conforman la API están, en su mayoría, expuestas en forma de funciones e identificadores al estilo C o en interfaces al estilo COM. En ambos casos se requiere de algún tipo de nivel de encapsulación o dirección, y ninguno de los dos es muy práctico para trabajar.

El desafío del desarrollador de C++ es determinar el nivel de encapsulación. Los desarrolladores que crecieron con bibliotecas tales como MFC o ATL pueden tender a encapsularlo todo dentro de clases y funciones miembro, ya que ese es el patrón de las bibliotecas de C++ que han usado durante tanto tiempo. Puede que otros desarrolladores se burlen de cualquier tipo de encapsulación y usen las funciones, identificadores e interfaces directamente. Podría decirse que estos otros desarrolladores en realidad no son desarrolladores de C++, sino que desarrolladores de C con problemas de identidad. Pero creo que existe un punto medio más natural para el desarrollador de C++ contemporáneo.

A medida que vuelvo a retomar mi columna aquí en MSDN Magazine, mostraré cómo usar C++0x (o C++ 2011, como se llamará probablemente) junto con la API de Windows para sacar el arte del desarrollo nativo de software de Windows de la edad de las tinieblas. Durante los siguientes meses, realizaré un extenso recorrido por la API del grupo de subprocesos de Windows. Sígame y podrá descubrir cómo escribir increíbles aplicaciones escalables sin requerir de nuevos lenguajes extravagantes ni de tiempos de ejecución complicados o costosos. Todo lo que necesita es el excelente compilador Visual C++, la API de Windows y un deseo de perfeccionar su trabajo.

Al igual que todos los buenos proyectos, es necesario establecer algunas bases para tener un buen comienzo. Entonces, ¿cómo puedo “encapsular” la API de Windows? En vez de obstaculizar todas las columnas futuras con estos detalles, en esta columna voy a explicar el enfoque que recomiendo, y simplemente me baso en esto a medida que avanzo. Por ahora, dejaré de lado el problema de las interfaces del tipo COM, ya que no las necesitaremos en las siguientes columnas.

La API de Windows consta de diversas bibliotecas que exponen un conjunto de funciones en el estilo C y uno o más punteros opacos llamados identificadores. Por lo general, estos identificadores representan una biblioteca o recurso de sistema. Se proporcionan funciones para crear, manipular y liberar los recursos mediante los identificadores. Por ejemplo, la función CreateEvent crea un objeto de evento, que devuelve un identificador al objeto de evento. Para liberar el identificador y decirle al sistema que se ha dejado de usar el objeto de evento, basta con pasar el identificador a la función CloseHandle. Si no existe otro identificador pendiente en el mismo objeto de evento, el sistema lo destruirá:

auto h = CreateEvent( ... );
CloseHandle(h);

Para los principiantes en C++

Por si usted no está muy familiarizado con C++ 2011, quisiera destacar que la palabra clave auto instruye al compilador que debe deducir el tipo de la variable de la expresión de inicialización. Esto es muy útil cuando se desconoce el tipo de una expresión, como sucede a menudo en la metaprogramación, o cuando simplemente se desean guardar unas secuencias de teclas presionadas.

Pero casi nunca debiera escribir código como este. Sin lugar a duda, la característica más valiosa en C++ son las clases. Las plantillas son geniales y la Biblioteca de plantillas estándar (STL) es mágica, pero sin las clases nada en C++ tiene sentido. Es gracias a las clases que los programas de C++ son concisos y confiables. No me refiero a las funciones virtuales, la herencia ni otras características extravagantes. Simplemente me refiero al constructor y al destructor. A menudo es todo lo que necesita y ¿adivine qué?, no cuesta nada. En la práctica, hay que estar consciente de la sobrecarga impuesta por el control de las excepciones y abordaré este asunto al final de esta columna.

Para dominar la API de Windows y hacerla accesible a los desarrolladores de C++ modernos, se necesita una clase que encapsule un identificador. Sí, puede que su biblioteca favorita de C++ ya tenga un contenedor para los identificadores, pero ¿fue diseñada desde cero para C++ 2011? ¿Es posible guardar estos identificadores con toda confianza en un contenedor de la STL y pasarlos por el programa sin que se pierda de vista quién es el propietario?

Las clases de C++ son la abstracción perfecta para los identificadores. Observe que no dije “objetos”. Recuerde que un identificador es un representante de un objeto dentro del programa y, por lo general, no es el objeto en si. Es el identificador es el que hay que administrar, no el objeto. Puede que en algunas situaciones convenga tener una relación uno a uno entre un objeto de la API de Windows y una clase C++, pero eso es otro asunto.

Aunque los identificadores generalmente son opacos, existen diferentes tipos de identificadores y, frecuentemente diferencias semánticas sutiles que exigen una plantilla de clase para encapsular adecuadamente los identificadores en una forma general. Se requiere de parámetros en la plantilla para especificar el tipo de identificador y las características o rasgos propios del identificador.

En C++ generalmente se usan las clases de rasgos para proporcionar información acerca de un tipo dado. De esta forma, puedo escribir una plantilla de clase única para los identificadores y proporcionar diferentes clases de rasgos para los diferentes tipos de identificadores de la API de Windows. La clase de rasgos de un identificador también tiene que definir cómo se libera el identificador, de modo que la plantilla de clase del identificador pueda liberarla automáticamente cuando haga falta. Como tal, aquí hay una clase de rasgos para los identificadores de evento:

struct handle_traits
{
  static HANDLE invalid() throw()
  {
    return nullptr;
  }

  static void close(HANDLE value) throw()
  {
    CloseHandle(value);
  }
};

Como muchas bibliotecas de la API de Windows comparten esta semántica, ésta no está limitada a los objetos de evento. Como se puede observar, la clase de rasgos consta únicamente de funciones miembro estáticas. Como resultado, el compilador puede introducir el código fácilmente en línea, lo que impide la sobrecarga, mientras proporciona bastante flexibilidad para la metaprogramación.

La función invalid devuelve el valor de un identificador no válido. Este normalmente es nullptr, una palabra clave nueva de C++ 2011 que representa un valor de puntero nulo. A diferencia de las alternativas tradicionales, nullptr está tipificado en forma estricta, por lo que funciona bien con las plantillas y la sobrecarga de funciones. Como hay casos donde un identificador no válido se define como algo distinto de nullptr, para esos casos existe la inclusión de la función invalid en la clase de rasgos. La función close encapsula el mecanismo por el cual el identificador se cierra o libera.

Dado el diseño de la clase de rasgos, puedo comenzar a definir la plantilla de clase del identificador, como se muestra en la Figura 1.

Figura 1 La plantilla de clase del identificador

template <typename Type, typename Traits>
class unique_handle
{
  unique_handle(unique_handle const &);
  unique_handle & operator=(unique_handle const &);

  void close() throw()
  {
    if (*this)
    {
      Traits::close(m_value);
    }
  }

  Type m_value;

public:

  explicit unique_handle(Type value = Traits::invalid()) throw() :
    m_value(value)
  {
  }

  ~unique_handle() throw()
  {
    close();
  }

Le puse el nombre unique_handle porque es similar en espíritu a la plantilla de clase unique_ptr estándar. Muchas bibliotecas también usan tipos y semánticas de identificador idénticos, y por lo tanto conviene proporcionar un typedef para el caso más frecuente, llamado simplemente handle:

typedef unique_handle<HANDLE, handle_traits> handle;

Ahora puedo crear un objeto de evento y un administrarlo del siguiente modo:

handle h(CreateEvent( ... ));

He declarado el constructor de copias y el operador de asignación de copias como privados y los he dejado sin implementar. Así impido que el compilador los genere automáticamente, ya que rara vez se usan con los identificadores. La API de Windows permite copiar ciertos tipos de identificadores, pero este es un concepto bastante diferente de la semántica de copia de C++.

El parámetro value del constructor depende de la clase de rasgos para proporcionar un valor predeterminado. El destructor llama la función miembro privada close, la que a su vez depende de la clase de rasgos para cerrar el identificador de ser necesario. Esto me proporciona un identificador seguro frente a las excepciones y que se puede apilar.

Pero no he terminado aún. La función de miembro close depende de la presencia de una conversión booleana para determinar si hay que cerrar o no el identificador. Aunque C++ 2011 introduce funciones de conversión explícita, éstas aún no están disponibles en Visual C++, por lo que uso un método común para la conversión booleana. Así evito las temidas conversiones implícitas que, de otro modo estarían permitidas por el compilador:

private:

  struct boolean_struct { int member; };
  typedef int boolean_struct::* boolean_type;

  bool operator==(unique_handle const &);
  bool operator!=(unique_handle const &);

public:

  operator boolean_type() const throw()
  {
    return Traits::invalid() != m_value ? &boolean_struct::member : nullptr;
  }

Esto significa que ahora puedo probar si tengo un identificador válido o no, pero sin permitir que pasen desapercibidas las conversiones peligrosas:

unique_handle<SOCKET, socket_traits> socket;
unique_handle<HANDLE, handle_traits> event;

if (socket && event) {} // Are both valid?

if (!event) {} // Is event invalid?

int i = socket; // Compiler error!

if (socket == event) {} // Compiler error!

Si hubiera usado el operador booleano más obvio los dos últimos errores habrían pasado desapercibidos. Pero como esto permite comparar un socket con otro, es necesario implementar explícitamente los operadores de igualdad o declararlos como privados y dejarlos sin implementar.

La forma en que un objeto unique_handle es propietario de un identificador es análoga a la forma en que una plantilla de clase unique_ptr estándar es propietaria de un objeto y lo administra con un puntero. Por lo tanto tiene sentido implementar las funciones miembro get, reset y release familiares para administrar al identificador subyacente. La función de obtención es fácil:

Type get() const throw()
{
  return m_value;
}

La función de restablecimiento da un poco más de trabajo, pero está basada en lo que ya hemos analizado:

bool reset(Type value = Traits::invalid()) throw()
{
  if (m_value != value)
  {
    close();
    m_value = value;
  }

  return *this;
}

He tomado la libertad de alejarme un poco del patrón de unique_ptr y devolver un valor booleano en la función reset, para indicar si el objeto se restableció con un identificador válido o no. Esto resulta útil para el control de errores, al cual volveré en un momento. La implementación de la función de liberación a estas alturas ya debe ser obvia:

Type release() throw()
{
  auto value = m_value;
  m_value = Traits::invalid();
  return value;
}

¿Copiado o transferencia?

Para el toque final hay que considerar la semántica de copiado frente a la semántica de transferencia de recursos. Debido a que ya descarté la semántica de copiado para los identificadores, tiene sentido permitir la semántica de transferencia. Esto es de importancia fundamental si se desea guardar los identificadores en los contenedores de la STL. Tradicionalmente estos contenedores han admitido la semántica de copia, pero desde la introducción de C++ 2011 también se admite la semántica de transferencia.

Sin entrar en una extensa descripción de la semántica de transferencia de recursos y las referencias de rvalue, la idea es permitir que el valor de un objeto pase desde un objeto a otro en forma predecible para el desarrollador y de manera coherente para los autores de bibliotecas y el compilador.

Antes de C++ 2011 los desarrolladores tenían que realizar todo tipo de trucos complejos para evitar la tendencia excesiva del lenguaje (y por extensión de la STL) a copiar los objetos. Frecuentemente el compilador creaba una copia de un objeto para destruir inmediatamente el original. Con la semántica de transferencia de recursos el desarrollador puede declarar que un objeto no se usará más y su valor se moverá a otra parte, a menudo con el solo cambio de un puntero.

En algunos casos es necesario que el desarrollador sea explícito e indique esto; pero la mayoría de las veces el compilador puede sacar ventaja de los objetos con semántica de transferencia para realizar unas optimizaciones alucinantemente eficientes que no habían sido posibles nunca antes. Lo bueno es que es muy fácil habilitar la semántica de transferencia las clases. Así como el copiado depende del constructor de copias y un operador de asignación de copias, la semántica de transferencia depende del constructor de transferencia y de un operador de asignación de transferencia:

unique_handle(unique_handle && other) throw() :
  m_value(other.release())
{
}

unique_handle & operator=(unique_handle && other) throw()
{
  reset(other.release());
  return *this;
}

La referencia rvalue

C++ 2011 introduce una nueva clase de referencias llamada referencia rvalue, que se declara mediante &&. Esto es lo que se usa en los miembros de unique_handle en las líneas previas. Aunque se parecen a las referencias antiguas (llamadas ahora referencia lvalue), las referencias rvalue tienen algunas reglas diferentes para la inicialización y la resolución de sobrecarga. Por el momento dejaré el tema hasta ahí (regresaré más adelante). A estas alturas, la principal ventaja de un identificador con una semántica de transferencia de recursos son los identificadores se pueden guardar correcta y eficientemente en los contenedores de la STL.

Control de errores

Esto sería todo en cuanto a la clase unique_handle. El tema final para este mes (y para prepararnos para las siguientes columnas) es el control de errores. Podríamos debatir interminablemente acerca de los pros y los contras de las excepciones frente a los códigos de error, pero cuando se desean admitir las bibliotecas estándar de C++, simplemente hay que acostumbrarse a las excepciones. Pero claro, como la API de Windows utiliza códigos de error hay que transigir.

Mi actitud frente al control de errores es hacer lo menos posible: escribir código que sea seguro frente a las excepciones, pero evitar interceptarlas. Cuando no hay controladores para las excepciones, Windows genera automáticamente un informe de error que incluye un pequeño volcado del bloqueo que se puede depurar en el examen analítico final. Sólo genere excepciones cuando ocurran errores inesperados en tiempo de ejecución, y controle todo lo demás con códigos de error. De este modo, cuando se genere una excepción habrá certeza de que se trata de un error en el código o se ha producido alguna catástrofe en el equipo.

Me gusta dar el ejemplo del acceso al registro de Windows. Si no se puede escribe un valor en el registro, esto generalmente se considera como un síntoma de un problema mayor que será difícil de controlar de forma razonable en el programa. Por lo tanto se debe generar una excepción. Pero el caso cuando no se pueda leer un valor del registro se debe anticipar y controlar correctamente. En este caso no se debe generar una excepción sino devolver un valor booleano o un enum que indique si el valor se puede leer o por qué no se puede.

La API de Windows no es particularmente coherente en la forma en que se controlan los errores; ese es el resultado de una API que ha evolucionado a través de los años. En gran parte, los errores se devuelven como valores BOOL o HRESULT. Existen algunos otros, que tiendo a controlar en forma explícita al comparar el valor de retorno con los valores documentados.

Si decido que para que mi programa siga funcionando en forma confiable la llamada a una función dada debe ser satisfactoria, uso una de las funciones indicadas en la Figura 2 para comprobar el valor de retorno.

Figura 2 Comprobación del valor de retorno

inline void check_bool(BOOL result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_bool(bool result)
{
  if (!result)
  {
    throw check_failed(GetLastError());
  }
}

inline void check_hr(HRESULT result)
{
  if (S_OK != result)
  {
    throw check_failed(result);
  }
}

template <typename T>
void check(T expected, T actual)
{
  if (expected != actual)
  {
    throw check_failed(0);
  }
}

Quisiera hacer dos alcances acerca de estas funciones. Primero, la función check_bool está sobrecargada, y por lo tanto también permite comprobar la validez de un objeto identificador, el que con toda razón no permite la conversión implícita a BOOL. Segundo, la función check_hr compara en forma explícita con S_OK en vez de emplear la macro SUCCEEDED usada comúnmente. Esto impide que se acepten en forma subterránea otros códigos de éxito sospechosos como por ejemplo S_FALSE, que casi nunca hace lo que el desarrollador espera.

Mi primer intento al escribir estas funciones de comprobación resultó en un conjunto de sobrecargas. Pero a medida que las usé en diversos proyectos, me di cuenta que la API de Windows simplemente define demasiados tipos de resultado y macros, y que por lo tanto simplemente no se puede crear un conjunto de sobrecargas que funcione en todos los casos. Esta es la razón de los nombres de función decorados. Encontré algunos casos donde no se identificaban los errores debido a una resolución de sobrecarga inesperada. El caso cuando se genera el tipo check_failed es bastante sencillo:

struct check_failed
{
  explicit check_failed(long result) :
    error(result)
  {
  }

  long error;
};

Puedo decorarlo con toda clase de características extravagantes, como por ejemplo mensajes de error, pero ¿para qué? Como incluyo el valor de error puedo realizar fácilmente una autopsia en el caso de las aplicaciones bloqueadas. Más allá de eso, el resto es un obstáculo.

Dadas estas funciones de comprobación, puedo crear un objeto de evento, señalarlo y lanzar una excepción cuando algo sale mal:

handle h(CreateEvent( ... ));

check_bool(h);

check_bool(SetEvent(h.get()));

Control de excepciones

El otro problema con el control de las excepciones tiene que ver con la eficiencia. También aquí los desarrolladores están divididos, pero generalmente esto se debe a prejuicios sin asidero.

Los costos del control de excepciones tienen dos orígenes. Primero, la generación de las excepciones, que tiende a ser más lenta que usar códigos de error. Esto es uno de los motivos por los cuales sólo se deben lanzar excepciones en caso de errores fatales; si todo va bien, nunca habrá que pagar este precio.

La segunda causa de los problemas de rendimiento (y la más común) se relaciona con la sobrecarga en tiempo de ejecución para garantizar que se llamen los destructores necesarios, en el caso improbable de que se genere una excepción. Se necesita código para hacer un seguimiento de los destructores que se deben ejecutar, y por supuesto esto también aumenta el tamaño de la pila, lo que en las bases de código grandes puede afectar de forma significativa el rendimiento. Tenga presente que este precio se paga independientemente de si se lanza o no la excepción, y por lo tanto es imprescindible minimizar este ítem para garantizar un buen rendimiento.

Debido a todo lo anterior hay que garantizar que el compilador tenga una idea clara sobre las funciones que puedan lanzar excepciones. Si el compilador puede probar que determinadas funciones no van a generar excepciones puede optimizar el código para definir y administrar la pila. Es por esto que decoré toda la plantilla de la clase del identificador y las funciones miembro de clase de rasgos con la especificación de excepciones. Aunque esta optimización se dejó de usar en C++ 2011, sigue siendo una optimización importante en la plataforma.

Esto es todo por este mes. Ahora ya cuenta con uno de los ingredientes clave para escribir programas confiables con la API de Windows. Sígame el próximo mes cuando explore la API del grupo de subprocesos de Windows.        

Kenny Kerr* es un artesano del software que siente una pasión por el desarrollo nativo de Windows. Puede ponerse en contacto con él en kennykerr.ca.*