programacion - ¿Creando "clases" en C, en la pila frente al montón?
heap programacion (12)
Cada vez que veo una C "clase" (cualquier estructura que debe usarse accediendo a funciones que toman un puntero como primer argumento) las veo implementadas así:
typedef struct
{
int member_a;
float member_b;
} CClass;
CClass* CClass_create();
void CClass_destroy(CClass *self);
void CClass_someFunction(CClass *self, ...);
...
Y en este caso CClass_create
siempre malloc
es su memoria y devuelve un puntero a eso.
Cada vez que veo algo new
en C ++ innecesariamente, por lo general parece volver locos a los programadores de C ++, pero esta práctica parece aceptable en C. ¿Qué da? ¿Hay alguna razón detrás de por qué las "clases" de estructuras asignadas en el montón son tan comunes?
¿Su pregunta es "por qué en C es normal asignar memoria dinámicamente y en C ++ no es así"?
C ++ tiene muchos constructos que hacen que los nuevos sean redundantes. copiar, mover y normalizar constructores, destructores, la biblioteca estándar, asignadores.
Pero en C no puedes evitarlo.
Asumiendo, como en su pregunta, CClass_create
y CClass_destroy
usan malloc/free
, entonces para mí hacer lo siguiente es una mala práctica:
void Myfunc()
{
CClass* myinstance = CClass_create();
...
CClass_destroy(myinstance);
}
porque podríamos evitar un malloc y un free fácilmente:
void Myfunc()
{
CClass myinstance; // no malloc needed here, myinstance is on the stack
CClass_Initialize(&myinstance);
...
CClass_Uninitialize(&myinstance);
// no free needed here because myinstance is on the stack
}
con
CClass* CClass_create()
{
CClass *self= malloc(sizeof(CClass));
CClass_Initialize(self);
return self;
}
void CClass_destroy(CClass *self);
{
CClass_Uninitialize(self);
free(self);
}
void CClass_Initialize(CClass *self)
{
// initialize stuff
...
}
void CClass_Uninitialize(CClass *self);
{
// uninitialize stuff
...
}
En C ++ también preferimos hacer esto:
void Myfunc()
{
CClass myinstance;
...
}
que esto:
void Myfunc()
{
CClass* myinstance = new CCLass;
...
delete myinstance;
}
Para evitar un new
/ delete
innecesario.
C carece de ciertas cosas que los programadores de C ++ dan por hecho a saber.
- especificadores públicos y privados
- constructores y destructores
La gran ventaja de este enfoque es que puedes ocultar la estructura en tu archivo C y forzar la construcción y destrucción correctas con tus funciones de crear y destruir.
Si expone la estructura en su archivo .h, esto significa que los usuarios pueden acceder directamente a los miembros, lo que rompe la encapsulación. Además, no forzar la creación permite la construcción incorrecta de su objeto.
Cambiaría el "constructor" a un void CClass_create(CClass*);
No devolverá una instancia / referencia de la estructura, pero se llamará a uno.
A partir de si está asignado en la "pila" o dinámicamente, depende totalmente de los requisitos de su escenario de uso. Sin CClass_create()
lo asigne, simplemente llame a CClass_create()
pasando la estructura asignada como un parámetro.
{
CClass stk;
CClass_create(&stk);
CClass *dyn = malloc(sizeof(CClass));
CClass_create(dyn);
CClass_destroy(&stk); // the local object lifetime ends here, dyn lives on
}
// and later, assuming you kept track of dyn
CClass_destroy(dyn); // destructed
free(dyn); // deleted
Solo tenga cuidado de no devolver una referencia a un local (asignado en la pila), porque eso es UB.
Sin void CClass_destroy(CClass*);
lo asigne, tendrá que llamar a void CClass_destroy(CClass*);
en el lugar correcto (el final de la vida útil de ese objeto), y si se asigna dinámicamente, también libera esa memoria.
Distinguir entre asignación / desasignación y construcción / destrucción, esos no son los mismos (incluso si en C ++ se pueden unir automáticamente).
En C, cuando algún componente proporciona una función de "creación", el implementador del componente también tiene el control sobre cómo se inicializa el componente. Por lo tanto, no solo emula el operator new
C ++ '' operator new
sino también el constructor de clase.
Renunciar a este control sobre la inicialización implica una mayor verificación de errores en las entradas, por lo que mantener el control hace que sea más fácil proporcionar un comportamiento consistente y predecible.
También tomo la excepción de que malloc
siempre se usa para asignar memoria. Este puede ser el caso a menudo, pero no siempre. Por ejemplo, en algunos sistemas integrados, encontrará que malloc
/ free
no se usa en absoluto. Las funciones X_create
pueden asignarse de otras maneras, por ejemplo, desde una matriz cuyo tamaño se fija en tiempo de compilación.
En general, el hecho de que vea un *
no significa que ha sido malloc
''d. Podría haber obtenido un puntero a variable global static
, por ejemplo; en su caso, de hecho, CClass_destroy()
no toma ningún parámetro que suponga que ya conoce alguna información sobre el objeto que se destruye.
Además, los punteros, sean o no malloc
''d, son la única forma que le permiten modificar el objeto.
No veo razones particulares para usar el montón en lugar de la pila: no se utiliza menos memoria. Lo que se necesita, sin embargo, para inicializar tales "clases" son las funciones de inicio / destrucción porque la estructura de datos subyacente puede necesitar contener datos dinámicos, por lo tanto, el uso de punteros.
En realidad, es una reacción violenta a C ++ que hace que "nuevo" sea demasiado fácil.
En teoría, usar este patrón de construcción de clase en C es idéntico al uso de "nuevo" en C ++, por lo que no debería haber diferencia. Sin embargo, la forma en que las personas tienden a pensar sobre los idiomas es diferente, por lo que la forma en que las personas reaccionan al código es diferente.
En C es muy común pensar en las operaciones exactas que la computadora tendrá que hacer para lograr sus objetivos. No es universal, pero es una mentalidad muy común. Se supone que se ha tomado el tiempo de realizar el análisis costo / beneficio del malloc / free.
En C ++, se ha vuelto mucho más fácil escribir líneas de código que hacen mucho por ti, sin siquiera darte cuenta. ¡Es bastante común que alguien escriba una línea de código, y ni siquiera se da cuenta de que pasó a llamar a 100 o 200 nuevos / elimina! Esto ha provocado una reacción adversa, en la que el desarrollador de C ++ se burlará de las noticias y las eliminará, por miedo a que las llamen accidentalmente por todo el lugar.
Estas son, por supuesto, generalizaciones. De ninguna manera todas las comunidades C y C ++ se ajustan a estos moldes. Sin embargo, si te molesta usar algo nuevo en lugar de poner cosas en el montón, esta puede ser la causa raíz.
Es bastante extraño que lo veas tan a menudo. Debes haber estado buscando un código "perezoso".
En C, la técnica que describes generalmente está reservada para tipos de biblioteca "opacos", es decir, tipos de estructuras cuyas definiciones se hacen intencionalmente invisibles para el código del cliente. Dado que el cliente no puede declarar tales objetos, la expresión idiomática tiene que ser realmente en la asignación dinámica en el código de biblioteca "oculto".
Cuando se oculta la definición de la estructura no se requiere, una expresión típica de C por lo general se ve de la siguiente manera
typedef struct CClass
{
int member_a;
float member_b;
} CClass;
CClass* CClass_init(CClass* cclass);
void CClass_release(CClass* cclass);
La función CClass_init
inicializa el objeto *cclass
y devuelve el mismo puntero como resultado. Es decir, la carga de asignar memoria para el objeto se coloca en la persona que llama y la persona que llama puede asignarla de la forma que considere adecuada.
CClass cclass;
CClass_init(&cclass);
...
CClass_release(&cclass);
Un ejemplo clásico de este modismo sería pthread_mutex_t
con pthread_mutex_init
y pthread_mutex_destroy
.
Mientras tanto, usar la técnica anterior para tipos no opacos (como en tu código original) es generalmente una práctica cuestionable. Es exactamente cuestionable como el uso gratuito de la memoria dinámica en C ++. Funciona, pero de nuevo, usar memoria dinámica cuando no se requiere está mal visto en C como en C ++.
Esto genera muchas respuestas porque de alguna manera se basa en las opiniones . Aún así, quiero explicar por qué personalmente prefiero que mis "Objetos C" sean asignados en el montón. La razón es tener todos mis campos ocultos (habla: privado ) del código de consumo. Esto se llama un puntero opaco . En la práctica, significa que su archivo de encabezado no define la struct
en uso, solo lo declara. Como consecuencia directa, el código de consumo no puede conocer el tamaño de la struct
y, por lo tanto, la asignación de la pila se vuelve imposible.
El beneficio es: el código de consumo nunca puede depender de la definición de la struct
, lo que significa que es imposible que de alguna manera el contenido de la struct
inconsistente desde el exterior y se evita la recompilación innecesaria del código de consumo cuando la struct
cambia.
El primer problema se aborda en c ++ al declarar que los campos son private
. Pero la definición de su class
todavía se importa en todas las unidades de compilación que la utilizan, por lo que es necesario volver a compilarla, incluso cuando solo cambian sus miembros private
. La solución que se usa a menudo en c ++ es el patrón pimpl
: tiene todos los miembros privados en una segunda struct
(o class
) que solo está definida en el archivo de implementación. Por supuesto, esto requiere que su pimpl
sea asignado en el montón.
Además, los lenguajes OOP modernos (como, por ejemplo, java o c# ) tienen medios para asignar objetos (y generalmente deciden si se trata de apilamiento o almacenamiento interno) sin que el código de llamada tenga conocimiento de su definición.
Hay varias razones para esto.
- Usar punteros "opacos"
- La falta de destructores
- Sistemas integrados (problema de desbordamiento de la pila)
- Contenedores
- Inercia
- "Pereza"
Discutamos brevemente.
Para punteros opacos , le permite hacer algo como:
struct CClass_;
typedef struct CClass_ CClass;
// the rest as in your example
Entonces, el usuario no ve la definición de struct CClass_
, aislándola de los cambios en ella y habilitando otras cosas interesantes, como implementar la clase de manera diferente para diferentes plataformas.
Por supuesto, esto prohíbe el uso de variables de pila de CClass
. Pero, OTOH, uno puede ver que esto no prohíbe la asignación estática de objetos CClass
(de algún grupo) - devuelto por CClass_create
o tal vez otra función como CClass_create_static
.
Falta de destructores : como el compilador C no destruirá automáticamente los objetos de la pila CClass
, debe hacerlo usted mismo (llamando manualmente a la función destructora). Entonces, el único beneficio que queda es el hecho de que la asignación de la pila es, en general, más rápida que la asignación de la pila. OTOH, no tiene que usar el montón, puede asignar desde un grupo, o un campo, o algo así, y eso puede ser casi tan rápido como la asignación de la pila, sin los posibles problemas de asignación de la pila que se describen a continuación.
Sistemas integrados : la pila no es un recurso "infinito", ¿sabes? Claro, para la mayoría de las aplicaciones en los sistemas operativos "regulares" de hoy (POSIX, Windows ...), casi lo es. Pero, en sistemas integrados, la pila puede ser tan baja como unos pocos KB. Eso es extremo, pero incluso los sistemas integrados "grandes" tienen pila que está en MB. Por lo tanto, se agotará si se usa en exceso. Cuando lo hace, la mayoría de las veces no hay garantía de lo que sucederá: AFAIK, en C y C ++, ese es el "Comportamiento indefinido". OTOH, CClass_create()
puede devolver el puntero NULL cuando no tengas memoria, y puedes manejar eso.
Contenedores : a los usuarios de C ++ les gusta la asignación de pila, pero si crea un std::vector
en la pila, su contenido se asignará en forma de pila. Puede modificar eso, por supuesto, pero ese es el comportamiento predeterminado, y hace que sea mucho más fácil decir que "todos los miembros de un contenedor están asignados en un montón" en lugar de tratar de averiguar cómo manejarlos si no lo están.
Inercia : bueno, el OO vino de SmallTalk. Todo es dinámico allí, entonces, la traducción "natural" a C es la forma de "poner todo en el montón". Entonces, los primeros ejemplos fueron así e inspiraron a otros durante muchos años.
" Pereza ": si sabes que solo quieres apilar objetos, necesitas algo como:
CClass CClass_make();
void CClass_deinit(CClass *me);
Pero, si desea permitir tanto la pila como el montón, debe agregar:
CClass *CClass_create();
void CClass_destroy(CClass *me);
Esto es más trabajo por hacer para el implementador, pero también es confuso para el usuario. Uno puede hacer interfaces ligeramente diferentes, pero no cambia el hecho de que necesita dos conjuntos de funciones.
Por supuesto, la razón de los "contenedores" también es parcialmente una razón de "holgazanería".
Porque una función solo puede devolver una estructura asignada de pila si no contiene punteros a otras estructuras asignadas. Si solo contiene objetos simples (int, bool, floats, chars y arrays de ellos pero ningún puntero ), puede asignarlo en la pila. Pero debe saber que si lo devuelve, se copiará. Si desea permitir punteros a otras estructuras, o desea evitar la copia, utilice el montón.
Pero si puedes crear la estructura en una unidad de nivel superior y solo usarla en funciones llamadas y nunca devolverla, entonces la pila es apropiada
Si la cantidad máxima de objetos de algún tipo que debe existir simultáneamente es fija, el sistema necesitará poder hacer algo con cada instancia "en vivo", y los elementos en cuestión no consumen demasiado dinero, el mejor el enfoque generalmente no es la asignación de pila ni la asignación de pila, sino más bien una matriz asignada estáticamente, junto con los métodos "crear" y "destruir". El uso de una matriz evitará la necesidad de mantener una lista vinculada de objetos, y permitirá manejar el caso en el que un objeto no puede destruirse inmediatamente porque está "ocupado" [por ejemplo, si los datos llegan a un canal por interrupción o DMA cuando el código de usuario decide que ya no está interesado en el canal y lo descarta, el código de usuario puede establecer un indicador de "desechar cuando esté listo" y regresar sin tener que preocuparse por tener una interrupción pendiente o almacenamiento de sobreescritura DMA que ya no está asignado a eso].
El uso de un grupo de tamaño fijo de objetos de tamaño fijo hace que la asignación y la desasignación sean mucho más predecibles que tomar almacenamiento de un montón de tamaños mixtos. El enfoque no es excelente en los casos donde la demanda es variable y los objetos ocupan mucho espacio (individual o colectivamente), pero cuando la demanda es más consistente (por ejemplo, una aplicación necesita 12 objetos todo el tiempo, y en ocasiones necesita hasta 3 más) puede funcionar mucho mejor que los enfoques alternativos. La única debilidad es que cualquier configuración debe realizarse en el lugar donde se declara el búfer estático, o debe realizarse mediante código ejecutable en los clientes. No hay forma de utilizar la sintaxis de inicialización de variable en un sitio de cliente.
Por cierto, al usar este enfoque no es necesario que el código del cliente reciba punteros para nada. En cambio, uno puede identificar los recursos usando cualquier número entero que sea conveniente. Además, si la cantidad de recursos nunca debe exceder el número de bits en un int
, puede ser útil tener algunas variables de estado para usar un bit por recurso. Por ejemplo, uno podría tener variables timer_notifications
(escritas solo a través del controlador de interrupción) y timer_acks
(escritas solo a través del código mainline) y especificar que el bit N de (timer_notifications ^ timer_acks)
se establecerá siempre que el temporizador N desee el servicio. Utilizando dicho enfoque, el código solo necesita leer dos variables para determinar si un temporizador necesita servicio, en lugar de tener que leer una variable para cada temporizador.