library - frameworks para c++
Diseño de biblioteca: ¿permitir al usuario decidir entre "solo encabezado" y enlazado dinámicamente? (7)
¿Es posible obtener lo mejor de ambos mundos?
En términos; Las limitaciones surgen porque las herramientas no son lo suficientemente inteligentes. Esta respuesta proporciona el mejor esfuerzo actual que aún es lo suficientemente portátil para ser utilizado con eficacia.
Recientemente comencé a pensar que este tipo de diseño no es muy bueno.
Deberia ser. Las bibliotecas solo para encabezados son ideales porque simplifican la implementación: hacen que el mecanismo de reutilización del lenguaje sea similar al de casi todos los demás, que es lo más sensato que se puede hacer. Pero esto es C ++. Las herramientas actuales de C ++ aún dependen de modelos de enlace de hace medio siglo que eliminan grados importantes de flexibilidad, como elegir qué puntos de entrada importar o exportar en un nivel individual sin tener que cambiar el código fuente original de la biblioteca. Además, C ++ carece de un sistema de módulos adecuado y aún depende de las operaciones glorificadas de copiar y pegar para funcionar (aunque esto es solo un factor secundario del problema en cuestión).
De hecho, MSVC es un poco mejor en este sentido. Es la única implementación importante que intenta lograr cierto grado de modularidad en C ++ (al intentar, por ejemplo, módulos de C ++ ). Y es el único compilador que realmente permite, por ejemplo, lo siguiente:
//// Module.c++
#pragma once
inline void Func() { /* ... */ }
//// Program1.c++
#include <Module.c++>
// Inlines or "vague" links Func(), whatever is better.
int main() { Func(); }
//// Program2.c++
// This forces Func() to be imported.
// The declaration must come *BEFORE* the definition.
__declspec(dllimport) __declspec(noinline) void Func();
#include <Module.c++>
int main() { Func(); }
//// Program3.c++
// This forces Func() to be exported.
__declspec(dllexport) __declspec(noinline) void Func();
#include <Module.c++>
Tenga en cuenta que esto se puede usar para importar y exportar de forma selectiva símbolos individuales de la biblioteca, aunque aún sea incómodo.
GCC también acepta esto (pero el orden de las declaraciones debe cambiarse) y Clang no tiene ninguna manera de lograr el mismo efecto sin cambiar la fuente de la biblioteca.
He creado varias bibliotecas de C ++ que actualmente son solo de encabezado . Tanto la interfaz como la implementación de mis clases están escritas en el mismo archivo .hpp
.
Recientemente comencé a pensar que este tipo de diseño no es muy bueno:
- Si el usuario desea compilar la biblioteca y vincularla dinámicamente, no puede.
- Cambiar una sola línea de código requiere una recompilación completa de los proyectos existentes que dependen de la biblioteca.
Sin embargo, disfruto mucho de los aspectos de las bibliotecas de solo encabezado: todas las funciones están potencialmente integradas y son muy fáciles de incluir en sus proyectos: no es necesario compilar / vincular nada, solo una simple directiva #include
.
¿Es posible obtener lo mejor de ambos mundos? Me refiero a permitir que el usuario elija cómo quiere usar la biblioteca. También aceleraría el desarrollo, ya que trabajaría en la biblioteca en "modo de enlace dinámico" para evitar tiempos de compilación absurdos y lanzaría mis productos terminados en "modo de solo encabezado" para maximizar el rendimiento.
El primer paso lógico es dividir la interfaz y la implementación en archivos .hpp
y .inl
.
Aunque no estoy seguro de cómo seguir adelante. He visto muchas bibliotecas LIBRARY_API
macros LIBRARY_API
a sus declaraciones de función / clase. ¿ LIBRARY_API
vez se necesitaría algo similar para permitir que el usuario elija?
Todas las funciones de mi biblioteca tienen el prefijo de la palabra clave en inline
, para evitar errores de "definición múltiple de ..." . ¿Asumo que la palabra clave sería reemplazada por una macro LIBRARY_INLINE
en los archivos .inl
? La macro se resolvería en inline
para el "modo de solo encabezado", y en nada para el "modo de enlace dinámico".
Razón fundamental
Ponga lo menos necesario en los archivos de cabecera y tanto como sea posible en los módulos de la biblioteca, por las razones que mencionó: dependencia en tiempo de compilación y tiempo de compilación largo. Las únicas buenas razones para los módulos de solo encabezado son:
plantillas genéricas para parámetros de plantillas definidas por el usuario;
Las funciones muy cortas de conveniencia cuando se enrola dan un rendimiento significativo.
En el caso 1, a menudo es posible ocultar alguna funcionalidad que no depende del tipo definido por el usuario en un archivo .cpp.
Conclusión
Si se adhiere a este razonamiento, entonces no hay opción: la funcionalidad de plantilla que debe permitir que los tipos definidos por el usuario no se pueda precompilar, pero requiere una implementación de solo encabezado. Otras funciones deben ocultarse al usuario en una biblioteca para evitar exponerlas a los detalles de la implementación.
El código con plantilla será necesariamente solo de cabecera: para crear una instancia de este código, los parámetros de tipo deben ser conocidos en el momento de la compilación. No hay forma de incrustar código de plantilla en bibliotecas compartidas. Solo .NET y Java admiten la creación de instancias JIT desde el código de bytes.
Re: código no de plantilla, para breves líneas, sugiero mantenerlo solo en el encabezado. Las funciones en línea le dan al compilador muchas más oportunidades para optimizar el código final.
Para evitar el "tiempo de compilación insano", Microsoft Visual C ++ tiene una característica de "encabezados precompilados". No creo que GCC tenga una característica similar.
Las funciones largas no deben estar en línea en ningún caso.
Tenía un proyecto que tenía bits de solo encabezado, bits de biblioteca compilados y algunos bits que no podía decidir a dónde pertenecía. Terminé teniendo archivos .inc, incluidos condicionalmente en .hpp o .cxx dependiendo de #ifdef. A decir verdad, el proyecto siempre se compiló en el modo "max inline", así que después de un tiempo me deshice de los archivos .inc y simplemente moví el contenido a los archivos .hpp.
En lugar de una biblioteca dinámica, podría tener una biblioteca estática precompilada y un archivo de encabezado fino. En una compilación rápida interactiva, obtiene el beneficio de no tener que volver a compilar el mundo si los cambios en la implementación cambian. Pero una versión de lanzamiento completamente optimizada puede hacer una optimización global y aún así puede descubrir que puede hacer funciones en línea. Básicamente, con la "generación de código de tiempo de enlace", el conjunto de herramientas hace el truco que estaba pensando.
Estoy familiarizado con el compilador de Microsoft, que sé con seguridad hace esto desde Visual Studio 2010 (si no es anterior).
Es sistema operativo y compilador específico. En Linux con un compilador GCC muy reciente (versión 4.9), puede producir una biblioteca estática utilizando la optimización interprocedural linktime .
Esto significa que usted construye su biblioteca con g++ -O2 -flto
tanto en tiempo de compilación como en el enlace de biblioteca, y que usa su biblioteca con g++ -O2 -flto
tanto en tiempo de compilación como de enlace del programa de invocación.
Esto es para complementar la respuesta de @Horstling.
Puede crear una biblioteca estática o dinámica . Al crear bibliotecas enlazadas estáticamente, el código compilado para todas las funciones / objetos se guardará en un archivo (con la extensión .lib en Windows). En el tiempo de enlace del proyecto principal (el proyecto que utiliza la biblioteca), estos códigos se vincularán a su ejecutable final junto con los códigos del proyecto principal. Así que el ejecutable final no tendría ninguna dependencia de tiempo de ejecución.
Las bibliotecas enlazadas dinámicamente se fusionarán en el proyecto principal en tiempo de ejecución (y no en tiempo de enlace). Cuando compila la biblioteca, obtiene un archivo .dll (que contiene el código compilado real) y un archivo .lib (que contiene datos suficientes para que el compilador / tiempo de ejecución encuentre funciones / objetos en el archivo .dll). En el momento del enlace, el ejecutable se configurará para cargar el archivo .dll y usar el código compilado de ese archivo .dll según sea necesario. Necesitará distribuir el archivo .dll con su ejecutable para poder ejecutarlo.
No es necesario elegir entre la vinculación estática o dinámica (o solo el encabezado) al diseñar su biblioteca, crea múltiples proyectos / makefiles, uno para crear una .lib estática, otro para crear un par de .lib / .dll y distribuir Ambas versiones, para que el usuario pueda elegir entre. (Necesitará usar macros de preprocesador como las sugeridas en @Horstling).
No puede colocar ninguna plantilla en una biblioteca precompilada, a menos que use una técnica llamada Explicit Instantiation , que limita los parámetros de la plantilla.
También tenga en cuenta que los compiladores / enlazadores modernos generalmente no respetan el modificador en línea. Pueden alinear una función incluso si no está designada como en línea, o pueden llamar dinámicamente a otra que tenga un modificador en línea, según lo consideren adecuado. (En cualquier caso, recomendaré explícitamente colocar en línea donde corresponda para una máxima compatibilidad). Por lo tanto, no habrá ninguna penalización en el rendimiento en tiempo de ejecución si utiliza una biblioteca enlazada estáticamente en lugar de una biblioteca solo de encabezado (y, por supuesto, habilita las optimizaciones del compilador / enlazador). Como otros han sugerido, para funciones realmente pequeñas que seguramente se beneficiarán de ser llamadas en línea, es una práctica recomendada colocarlas en archivos de encabezado, de modo que las bibliotecas enlazadas dinámicamente tampoco sufrirán una pérdida significativa de rendimiento. (En cualquier caso, las funciones de alineación solo afectarán el rendimiento de las funciones que se llaman con mucha frecuencia, dentro de bucles que se llamarán miles / millones de veces).
En lugar de poner funciones integradas en los archivos de encabezado (con un #include "foo.cpp"
en su encabezado), puede cambiar la configuración de makefile / project y agregar foo.cpp a la lista de archivos de origen que se compilarán. De esta manera, si cambia la implementación de alguna función, no será necesario volver a compilar todo el proyecto y solo se volverá a compilar foo.cpp. Como mencioné anteriormente, el compilador optimizador seguirá incorporando sus pequeñas funciones, y no tiene que preocuparse por eso.
Si utiliza / diseña una biblioteca precompilada, debe considerar el caso en el que la biblioteca se compila con una versión diferente del compilador para el proyecto principal. Cada versión de compilador diferente (incluso configuraciones diferentes, como Debug o Release) utiliza un tiempo de ejecución de C diferente (cosas como memcpy, printf, fopen, ...) y el tiempo de ejecución de la biblioteca estándar de C ++ (cosas como std :: vector <>, std :: cuerda, ...). Estas diferentes implementaciones de biblioteca pueden complicar la vinculación, o incluso crear errores de tiempo de ejecución.
Como regla general, siempre evite compartir objetos de tiempo de ejecución del compilador (estructuras de datos que no están definidas por estándares, como FILE *) en las bibliotecas, ya que las estructuras de datos incompatibles darán lugar a errores de tiempo de ejecución.
Al vincular su proyecto, las funciones de tiempo de ejecución de C / C ++ deben estar vinculadas a su biblioteca .lib o .lib / .dll, o su archivo ejecutable .exe. El propio tiempo de ejecución de C / C ++ se puede vincular como biblioteca estática o dinámica (puede establecer esto en la configuración de makefile / project).
Encontrará que la vinculación dinámica con el tiempo de ejecución de C / C ++ tanto en la biblioteca como en el proyecto principal (incluso cuando compila la biblioteca como una biblioteca estática) evita la mayoría de los problemas de vinculación (con implementaciones de funciones duplicadas en varias versiones de tiempo de ejecución). Por supuesto, necesitaría distribuir DLL de tiempo de ejecución para todas las versiones usadas con su ejecutable y biblioteca.
Hay escenarios que requieren vinculación estática con el tiempo de ejecución de C / C ++, y el mejor enfoque en estos casos sería compilar la biblioteca con la misma configuración de compilador que el proyecto principal para evitar problemas de vinculación.
Nota preliminar: estoy asumiendo un entorno Windows, pero esto debería ser fácilmente transferible a otros entornos.
Tu biblioteca tiene que estar preparada para cuatro situaciones:
- Se utiliza como biblioteca de sólo encabezado
- Utilizado como biblioteca estática
- Utilizado como biblioteca dinámica (las funciones son importadas)
- Construido como biblioteca dinámica (las funciones son exportadas)
Así que formemos cuatro definiciones de preprocesador para esos casos: INLINE_LIBRARY
, STATIC_LIBRARY
, IMPORT_LIBRARY
y EXPORT_LIBRARY
(es solo un ejemplo; es posible que desee utilizar algún esquema de nomenclatura sofisticado). El usuario tiene que definir uno de ellos, dependiendo de lo que quiera.
Entonces puedes escribir tus encabezados así:
// foo.hpp
#if defined(INLINE_LIBRARY)
#define LIBRARY_API inline
#elif defined(STATIC_LIBRARY)
#define LIBRARY_API
#elif defined(EXPORT_LIBRARY)
#define LIBRARY_API __declspec(dllexport)
#elif defined(IMPORT_LIBRARY)
#define LIBRARY_API __declspec(dllimport)
#endif
LIBRARY_API void foo();
#ifdef INLINE_LIBRARY
#include "foo.cpp"
#endif
Su archivo de implementación se ve como de costumbre:
// foo.cpp
#include "foo.hpp"
#include <iostream>
void foo()
{
std::cout << "foo";
}
Si se define INLINE_LIBRARY
, las funciones se declaran en línea y la implementación se incluye como un archivo .inl.
Si se define STATIC_LIBRARY
, las funciones se declaran sin ningún especificador, y el usuario debe incluir el archivo .cpp en su proceso de compilación.
Si se define IMPORT_LIBRARY
, las funciones se importan y no hay necesidad de ninguna implementación.
Si se define EXPORT_LIBRARY
, las funciones se exportan y el usuario tiene que compilar esos archivos .cpp.
Cambiar entre estática / importar / exportar es algo muy común, pero no estoy seguro de que agregar un encabezado solo a la ecuación sea algo bueno. Normalmente, hay buenas razones para definir algo en línea o no hacerlo.
Personalmente, me gusta poner todo en archivos .cpp a menos que realmente tenga que estar en línea (como las plantillas) o tenga sentido en cuanto al rendimiento (funciones muy pequeñas, generalmente de una sola línea). Esto reduce tanto el tiempo de compilación como, y mucho más importante, las dependencias.
Pero si elijo definir algo en línea, siempre lo pongo en archivos .inl separados, solo para mantener los archivos de encabezado limpios y fáciles de entender.