¿Qué sucede con la basura en C ++?

51

Java tiene un GC automático que de vez en cuando detiene el mundo, pero se ocupa de la basura en un montón. Ahora las aplicaciones C / C ++ no tienen estos bloqueos STW, su uso de memoria tampoco crece infinitamente. ¿Cómo se logra este comportamiento? ¿Cómo se cuidan los objetos muertos?

    
pregunta Ju Shua 16.06.2016 - 16:26

8 respuestas

101

El programador es responsable de garantizar que los objetos que crearon a través de new se eliminen a través de delete . Si se crea un objeto, pero no se destruye antes de que el último puntero o la referencia a él salga fuera del alcance, cae a través de las grietas y se convierte en un Pérdida de memoria .

Desafortunadamente para C, C ++ y otros lenguajes que no incluyen un GC, esto simplemente se acumula con el tiempo. Puede hacer que una aplicación o el sistema se quede sin memoria y no pueda asignar nuevos bloques de memoria. En este punto, el usuario debe recurrir a la finalización de la aplicación para que el sistema operativo pueda recuperar la memoria utilizada.

En cuanto a la mitigación de este problema, hay varias cosas que hacen que la vida de un programador sea mucho más fácil. Estos están soportados principalmente por la naturaleza del alcance .

int main()
{
    int* variableThatIsAPointer = new int;
    int variableInt = 0;

    delete variableThatIsAPointer;
}

Aquí, creamos dos variables. Existen en Block Scope , según lo definido por el {} llaves. Cuando la ejecución se mueve fuera de este ámbito, estos objetos se eliminarán automáticamente. En este caso, variableThatIsAPointer , como su nombre lo indica, es un puntero a un objeto en la memoria. Cuando se sale del alcance, el puntero se elimina, pero el objeto al que apunta permanece. Aquí, delete este objeto antes de que se salga del ámbito para garantizar que no haya pérdida de memoria. Sin embargo, también podríamos haber pasado este puntero a otra parte y esperar que se elimine más adelante.

Esta naturaleza de alcance se extiende a las clases:

class Foo
{
public:
    int bar; // Will be deleted when Foo is deleted
    int* otherBar; // Still need to call delete
}

Aquí, se aplica el mismo principio. No tenemos que preocuparnos por bar cuando se borra Foo . Sin embargo, para otherBar , solo se elimina el puntero. Si otherBar es el único puntero válido a cualquier objeto al que apunta, probablemente deberíamos delete en el destructor de Foo . Este es el concepto de conducción detrás de RAII

  

la asignación de recursos (adquisición) se realiza durante la creación del objeto (específicamente la inicialización), por parte del constructor, mientras que la desasignación de recursos (liberación) se realiza durante la destrucción del objeto (específicamente la finalización), por el destructor. Por lo tanto, se garantiza que el recurso se mantendrá entre el momento en que finaliza la inicialización y comienza la finalización (mantener los recursos es una clase invariante), y se mantendrá solo cuando el objeto esté vivo. Por lo tanto, si no hay fugas de objetos, no hay fugas de recursos.

RAII también es la fuerza motriz típica detrás de Smart Pointers . En la biblioteca estándar de C ++, estos son std::shared_ptr , std::unique_ptr y std::weak_ptr ; aunque he visto y usado otras implementaciones de shared_ptr / weak_ptr que siguen los mismos conceptos. Para estos, un contador de referencia rastrea cuántos punteros hay para un objeto dado, y automáticamente delete s el objeto una vez que no hay más referencias a él.

Más allá de eso, todo se reduce a prácticas y disciplina adecuadas para que un programador se asegure de que su código maneje los objetos correctamente.

    
respondido por el Thebluefish 28.06.2016 - 23:04
84

C ++ no tiene recolección de basura.

Se requiere que las aplicaciones de C ++ eliminen su propia basura.

Los programadores de aplicaciones C ++ deben entender esto.

Cuando se olvidan, el resultado se denomina "pérdida de memoria".

    
respondido por el John R. Strohm 16.06.2016 - 16:29
43

En C, C ++ y otros sistemas sin un recolector de basura, el lenguaje y sus bibliotecas le ofrecen al desarrollador facilidades para indicar cuándo se puede reclamar la memoria.

La instalación más básica es almacenamiento automático . Muchas veces, el propio idioma garantiza que se eliminen los elementos:

int global = 0; // automatic storage

int foo(int a, int b) {
    static int local = 1; // automatic storage

    int c = a + b; // automatic storage

    return c;
}

En estos casos, el compilador se encarga de saber cuándo no se utilizan esos valores y reclama el almacenamiento asociado con ellos.

Cuando se usa almacenamiento dinámico , en C, la memoria se asigna tradicionalmente con malloc y se reclama con free . En C ++, la memoria se asigna tradicionalmente con new y se reclama con delete .

C no ha cambiado mucho a lo largo de los años, sin embargo, C ++ moderno evita new y delete por completo y se apoya en las instalaciones de la biblioteca (que a su vez usan new y delete adecuadamente):

  • los punteros inteligentes son los más famosos: std::unique_ptr y std::shared_ptr
  • pero los contenedores están mucho más extendidos en realidad: std::string , std::vector , std::map , ... todos administran la memoria asignada de forma dinámica de forma transparente

Hablando de shared_ptr , existe un riesgo: si se forma un ciclo de referencias y no se rompe, entonces puede haber una pérdida de memoria. Es responsabilidad del desarrollador evitar esta situación, la forma más simple es evitar shared_ptr por completo y la segunda más simple es evitar los ciclos en el nivel de tipo.

Como resultado, las pérdidas de memoria no son un problema en C ++ , incluso para los usuarios nuevos, siempre y cuando se abstengan de usar new , delete o std::shared_ptr . Esto es a diferencia de C, donde es necesaria una disciplina firme y, en general, insuficiente.

Sin embargo, esta respuesta no estaría completa sin mencionar a la hermana gemela de las fugas de memoria: punteros colgantes .

Un puntero colgante (o referencia colgante) es un peligro creado al mantener un puntero o referencia a un objeto que está muerto. Por ejemplo:

int main() {
    std::vector<int> vec;
    vec.push_back(1);     // vec: [1]

    int& a = vec.back();

    vec.pop_back();       // vec: [], "a" is now dangling

    std::cout << a << "\n";
}

El uso de un puntero colgante, o referencia, es Comportamiento no definido . En general, afortunadamente, este es un choque inmediato; muy a menudo, desafortunadamente, esto causa daños en la memoria primero ... y de vez en cuando surge un comportamiento extraño porque el compilador emite un código realmente extraño.

Undefined Behavior es el mayor problema con C y C ++ hasta la fecha, en términos de seguridad / corrección de los programas. Es posible que desee revisar Rust para un idioma sin recolector de basura y sin comportamiento indefinido.

    
respondido por el Matthieu M. 16.06.2016 - 18:04
27

C ++ tiene esta cosa llamada RAII . Básicamente, significa que la basura se limpia a medida que avanzas, en lugar de dejarla en una pila y dejar que el limpiador se encargue de ti. (Imaginenme en mi habitación viendo el fútbol: mientras bebo latas de cerveza y necesito otras nuevas, la forma en C ++ es llevar la lata vacía al contenedor de camino al refrigerador, la forma en C # es tirar el piso y espere a que la criada los recoja cuando venga a hacer la limpieza).

Ahora es posible perder memoria en C ++, pero para hacerlo es necesario dejar las construcciones habituales y volver a la forma C de hacer las cosas: asignar un bloque de memoria y realizar un seguimiento de dónde está ese bloque sin asistencia de idioma . Algunas personas olvidan este puntero y no pueden quitar el bloque.

    
respondido por el gbjbaanb 16.06.2016 - 17:07
26

Se debe tener en cuenta que, en el caso de C ++, es una idea errónea común de que "es necesario realizar una gestión manual de la memoria". De hecho, normalmente no realiza ninguna gestión de memoria en su código.

Objetos de tamaño fijo (con duración de alcance)

En la gran mayoría de los casos cuando necesita un objeto, el objeto tendrá una vida útil definida en su programa y se creará en la pila. Esto funciona para todos los tipos de datos primitivos incorporados, pero también para instancias de clases y estructuras:

class MyObject {
    public: int x;
};

int objTest()
{
    MyObject obj;
    obj.x = 5;
    return obj.x;
}

Los objetos de la pila se eliminan automáticamente cuando finaliza la función. En Java, los objetos siempre se crean en el montón y, por lo tanto, deben eliminarse mediante algún mecanismo, como la recolección de basura. Esto no es un problema para los objetos de la pila.

Objetos que administran datos dinámicos (con duración de alcance)

El uso del espacio en la pila funciona para objetos de un tamaño fijo. Cuando necesita una cantidad variable de espacio, como una matriz, se utiliza otro enfoque: la lista se encapsula en un objeto de tamaño fijo que administra la memoria dinámica por usted. Esto funciona porque los objetos pueden tener una función de limpieza especial, el destructor. Se garantiza que se llamará cuando el objeto salga del alcance y haga lo contrario al constructor:

class MyList {        
public:
    // a fixed-size pointer to the actual memory.
    int* listOfInts; 
    // constructor: get memory
    MyList(size_t numElements) { listOfInts = new int[numElements]; }
    // destructor: free memory
    ~MyList() { delete[] listOfInts; }
};

int listTest()
{
    MyList list(1024);
    list.listOfInts[200] = 5;
    return list.listOfInts[200];
    // When MyList goes off stack here, its destructor is called and frees the memory.
}

No hay administración de memoria en absoluto en el código donde se utiliza la memoria. Lo único que tenemos que asegurarnos es que el objeto que escribimos tenga un destructor adecuado. No importa cómo dejemos el alcance de listTest , ya sea a través de una excepción o simplemente regresando de él, se llamará al destructor ~MyList() y no necesitamos administrar ninguna memoria.

(Creo que es una decisión de diseño graciosa usar el operador binary NOT , ~ , para indicar el destructor. Cuando se usa en números, invierte los bits; en analogía , aquí indica que lo que hizo el constructor está invertido.)

Básicamente, todos los objetos C ++ que necesitan memoria dinámica utilizan esta encapsulación. Se le ha llamado RAII ("la adquisición de recursos es la inicialización"), que es una forma bastante extraña de expresar la idea simple de que los objetos se preocupan por sus propios contenidos; lo que adquieren es de ellos para limpiar.

Objetos polimórficos y vida útil más allá del alcance

Ahora, ambos de estos casos fueron para memoria que tiene una vida útil claramente definida: la vida útil es la misma que el alcance. Si no queremos que un objeto caduque cuando dejemos el alcance, hay un tercer mecanismo que puede administrar la memoria para nosotros: un puntero inteligente. Los punteros inteligentes también se utilizan cuando tiene instancias de objetos cuyo tipo varía en el tiempo de ejecución, pero que tienen una interfaz común o una clase base:

class MyDerivedObject : public MyObject {
    public: int y;
};
std::unique_ptr<MyObject> createObject()
{
    // actually creates an object of a derived class,
    // but the user doesn't need to know this.
    return std::make_unique<MyDerivedObject>();
}

int dynamicObjTest()
{
    std::unique_ptr<MyObject> obj = createObject();
    obj->x = 5;
    return obj->x;
    // At scope end, the unique_ptr automatically removes the object it contains,
    // calling its destructor if it has one.
}

Hay otro tipo de puntero inteligente, std::shared_ptr , para compartir objetos entre varios clientes. Solo eliminan el objeto contenido cuando el último cliente queda fuera del alcance, por lo que se pueden usar en situaciones en las que no se sabe cuántos clientes habrá ni cuánto tiempo usarán el objeto.

En resumen, vemos que realmente no realiza ninguna gestión de memoria manual. Todo está encapsulado y luego se cuida mediante una gestión de memoria completamente automática y basada en el alcance. En los casos en que esto no es suficiente, se utilizan punteros inteligentes que encapsulan la memoria en bruto.

Se considera una práctica extremadamente mala utilizar los punteros sin formato como propietarios de recursos en cualquier lugar en el código C ++, las asignaciones en bruto fuera de los constructores y las llamadas en bruto delete fuera de los destructores, ya que son casi imposibles de administrar cuando se producen excepciones, y en general difícil de usar de forma segura.

Lo mejor: esto funciona para todos los tipos de recursos

Uno de los mayores beneficios de RAII es que no se limita a la memoria. En realidad, proporciona una forma muy natural de administrar recursos como archivos y sockets (apertura / cierre) y mecanismos de sincronización como mutexes (bloqueo / desbloqueo). Básicamente, todos los recursos que pueden adquirirse y deben liberarse se administran exactamente de la misma manera en C ++, y nada de esta gestión se deja al usuario. Todo está encapsulado en clases que se adquieren en el constructor y se liberan en el destructor.

Por ejemplo, una función que bloquea un mutex generalmente se escribe así en C ++:

void criticalSection() {
    std::scoped_lock lock(myMutex); // scoped_lock locks the mutex
    doSynchronizedStuff();
} // myMutex is released here automatically

Otros idiomas hacen esto mucho más complicado, ya sea requiriéndole que lo haga manualmente (por ejemplo, en una cláusula finally ) o generen mecanismos especializados que resuelvan este problema, pero no de una manera especialmente elegante (generalmente más adelante en su vida, cuando suficientes personas han sufrido de la deficiencia). Dichos mecanismos son try-with-resources en Java y la declaración utilizando en C #, que son aproximaciones de RAII de C ++.

Entonces, para resumir, todo esto fue un relato muy superficial de RAII en C ++, pero espero que ayude a los lectores a comprender que la memoria e incluso la gestión de recursos en C ++ no suele ser "manual", sino en su mayoría automático.

    
respondido por el Felix Dombek 17.06.2016 - 02:17
9

Con respecto a C específicamente, el idioma no le proporciona herramientas para administrar la memoria asignada de forma dinámica. Usted es absolutamente responsable de asegurarse de que cada *alloc tenga un free correspondiente en algún lugar.

Donde las cosas se ponen realmente desagradables es cuando falla la asignación de recursos a la mitad; ¿lo intentas de nuevo? ¿Retrocedes y empiezas de nuevo desde el principio? ¿Retrocedes y sales con un error? ¿Simplemente abandonas y dejas que el sistema operativo se ocupe de ello?

Por ejemplo, aquí hay una función para asignar una matriz 2D no contigua. El comportamiento aquí es que si se produce un error de asignación en la mitad del proceso, revertimos todo y devolvemos una indicación de error utilizando un puntero NULO:

/**
 * Allocate space for an array of arrays; returns NULL
 * on error.
 */
int **newArr( size_t rows, size_t cols )
{
  int **arr = malloc( sizeof *arr * rows );
  size_t i;

  if ( arr ) // malloc returns NULL on failure
  {
    for ( i = 0; i < rows; i++ )
    {
      arr[i] = malloc( sizeof *arr[i] * cols );
      if ( !arr[i] )
      {
        /**
         * Whoopsie; we can't allocate any more memory for some reason.
         * We can't just return NULL at this point since we'll lose access
         * to the previously allocated memory, so we branch to some cleanup
         * code to undo the allocations made so far.  
         */
        goto cleanup;
      }
    }
  }
  goto done;

/**
 * We encountered a failure midway through memory allocation,
 * so we roll back all previous allocations and return NULL.
 */
cleanup:
  while ( i )         // this is why we didn't limit the scope of i to the for loop
    free( arr[--i] ); // delete previously allocated rows
  free( arr );        // delete arr object
  arr = NULL;

done:
  return arr;
}

Este código es feo a tope con esos goto s, pero, en ausencia de algún tipo de mecanismo de manejo de excepciones estructurado, esta es la única forma de resolver el problema sin solo rescatar por completo, especialmente si su código de asignación de recursos está anidado a más de un bucle. Esta es una de las pocas veces en que goto es en realidad una opción atractiva; de lo contrario, estás usando un montón de banderas y declaraciones if adicionales.

Puede hacerse la vida más fácil escribiendo funciones de asignación / desasignación dedicadas para cada recurso, algo como

Foo *newFoo( void )
{
  Foo *foo = malloc( sizeof *foo );
  if ( foo )
  {
    foo->bar = newBar();
    if ( !foo->bar ) goto cleanupBar;
    foo->bletch = newBletch(); 
    if ( !foo->bletch ) goto cleanupBletch;
    ...
  }
  goto done;

cleanupBletch:
  deleteBar( foo->bar );
  // fall through to clean up the rest

cleanupBar:
  free( foo );
  foo = NULL;

done:
  return foo;
}

void deleteFoo( Foo *f )
{
  deleteBar( f->bar );
  deleteBletch( f->bletch );
  free( f );
}
    
respondido por el John Bode 16.06.2016 - 21:28
2

He aprendido a clasificar los problemas de memoria en varias categorías diferentes.

  • Gotea una vez. Supongamos que un programa tiene fugas de 100 bytes en el momento del inicio, pero nunca más se fuga. Perseguir y eliminar esas fugas de una sola vez es bueno (me gusta tener un informe limpio por una capacidad de detección de fugas) pero no es esencial. A veces hay problemas más grandes que necesitan ser atacados.

  • Fugas repetidas. Una función que se llama repetitivamente durante el curso de una vida útil de programas que regularmente pierde memoria un gran problema. Estos goteos torturarán el programa, y posiblemente el sistema operativo, hasta la muerte.

  • Referencias mutuas. Si los objetos A y B se hacen referencia entre sí mediante punteros compartidos, debe hacer algo especial, ya sea en el diseño de esas clases o en el código que implementa / usa esas clases para romper la circularidad. (Esto no es un problema para los idiomas recolectados en la basura).

  • Recordar demasiado. Este es el primo malvado de las fugas de basura / memoria. RAII no ayudará aquí, ni tampoco la recolección de basura. Este es un problema en cualquier idioma. Si alguna variable activa tiene una ruta que lo conecta con una parte aleatoria de la memoria, esa parte aleatoria de la memoria no es basura. Hacer que un programa se vuelva olvidadizo para que pueda funcionar durante varios días es complicado. Hacer un programa que pueda ejecutarse durante varios meses (por ejemplo, hasta que falle el disco) es muy, muy complicado.

No he tenido un problema grave con las fugas durante mucho, mucho tiempo. El uso de RAII en C ++ ayuda en gran medida a abordar esos goteos y fugas. (Sin embargo, uno tiene que ser cuidadoso con los punteros compartidos). Mucho más importante que he tenido problemas con las aplicaciones cuyo uso de la memoria sigue creciendo y creciendo debido a las conexiones no compartidas con la memoria que ya no sirve de nada.

    
respondido por el David Hammen 16.06.2016 - 22:29
-6

Es responsabilidad del programador de C ++ implementar su propia forma de recolección de basura cuando sea necesario. De lo contrario, se producirá lo que se denomina "pérdida de memoria". Es bastante común que los lenguajes de "alto nivel" (como Java) se hayan incorporado en la recolección de basura, pero los lenguajes de "bajo nivel" como C y C ++ no lo hacen.

    
respondido por el xDr_Johnx 16.06.2016 - 19:36

Lea otras preguntas en las etiquetas

Comentarios Recientes

La regla general es que para cada constante que tiene la forma L, tiene la forma f (L). Al escribir S. esto se usa para implementar L + 1. Por lo tanto, el recolector de basura C ++ proporciona solo l1 y l2 a las copias c1, c2 y c3.Hay varias situaciones más que generan todos los tipos S a tipos genéricos, por ejemplo: es necesario determinar a dónde va s. Se especifica en c. Esto describe un valor que uno desea mantener. Vea las siguientes publicaciones: Sobre copia estática en escritura de programación genérica... Lee mas