¿Por qué los programadores de C++ deberían minimizar el uso de "nuevo"?
Me topé con la pregunta de Stack Overflow Pérdida de memoria con std::string cuando uso std::list<std::string> , y uno de los comentarios dice esto:
Deja de consumir
new
tanto. No veo ninguna razón por la que usaste nuevo en ningún lugar donde lo hiciste. Puedes crear objetos por valor en C++ y es una de las grandes ventajas de usar el lenguaje. No es necesario asignar todo lo que hay en el montón. Deja de pensar como un programador de Java .
No estoy muy seguro de qué quiere decir con eso.
¿Por qué los objetos deberían crearse por valor en C++ con la mayor frecuencia posible y qué diferencia hay internamente? ¿Interpreté mal la respuesta?
Existen dos técnicas de asignación de memoria ampliamente utilizadas: asignación automática y asignación dinámica. Normalmente, existe una región de memoria correspondiente para cada uno: la pila y el montón.
Pila
La pila siempre asigna memoria de forma secuencial. Puede hacerlo porque requiere que libere la memoria en el orden inverso (primero en entrar, último en salir: FILO). Esta es la técnica de asignación de memoria para variables locales en muchos lenguajes de programación. Es muy, muy rápido porque requiere una contabilidad mínima y la siguiente dirección a asignar está implícita.
En C++, esto se denomina almacenamiento automático porque el almacenamiento se reclama automáticamente al final del alcance. Tan pronto como {}
se completa la ejecución del bloque de código actual (delimitado mediante ), se recopila automáticamente la memoria para todas las variables de ese bloque. Este es también el momento en el que se invoca a los destructores para limpiar los recursos.
Montón
El montón permite un modo de asignación de memoria más flexible. La contabilidad es más compleja y la asignación es más lenta. Debido a que no existe un punto de liberación implícito, debe liberar la memoria manualmente, usando delete
o delete[]
( free
en C). Sin embargo, la ausencia de un punto de liberación implícito es la clave de la flexibilidad del montón.
Razones para utilizar la asignación dinámica
Incluso si el uso del montón es más lento y potencialmente conduce a pérdidas o fragmentación de memoria, existen casos de uso perfectamente buenos para la asignación dinámica, ya que es menos limitada.
Dos razones clave para utilizar la asignación dinámica:
No sabes cuánta memoria necesitas en el momento de la compilación. Por ejemplo, cuando lees un archivo de texto en una cadena, normalmente no sabes qué tamaño tiene el archivo, por lo que no puedes decidir cuánta memoria asignar hasta que ejecutes el programa.
Quiere asignar memoria que persistirá después de abandonar el bloque actual. Por ejemplo, es posible que desee escribir una función
string readfile(string path)
que devuelva el contenido de un archivo. En este caso, incluso si la pila pudiera contener todo el contenido del archivo, no podría regresar de una función y conservar el bloque de memoria asignado.
Por qué la asignación dinámica suele ser innecesaria
En C++ hay una construcción ordenada llamada destructor . Este mecanismo le permite administrar recursos alineando la vida útil del recurso con la vida útil de una variable. Esta técnica se llama RAII y es el punto distintivo de C++. "Envuelve" recursos en objetos. std::string
es un ejemplo perfecto. Este fragmento:
int main ( int argc, char* argv[] )
{
std::string program(argv[0]);
}
en realidad asigna una cantidad variable de memoria. El std::string
objeto asigna memoria usando el montón y la libera en su destructor. En este caso, no fue necesario administrar manualmente ningún recurso y aun así obtuvo los beneficios de la asignación de memoria dinámica.
En particular, implica que en este fragmento:
int main ( int argc, char* argv[] )
{
std::string * program = new std::string(argv[0]); // Bad!
delete program;
}
hay una asignación de memoria dinámica innecesaria. El programa requiere más escritura (!) e introduce el riesgo de olvidar desasignar la memoria. Lo hace sin ningún beneficio aparente.
Por qué debería utilizar el almacenamiento automático con la mayor frecuencia posible
Básicamente, el último párrafo lo resume. Usar el almacenamiento automático con la mayor frecuencia posible hace que sus programas:
- más rápido para escribir;
- más rápido cuando corre;
- menos propenso a pérdidas de memoria/recursos.
Puntos extra
En la pregunta mencionada, hay preocupaciones adicionales. En particular, la siguiente clase:
class Line {
public:
Line();
~Line();
std::string* mString;
};
Line::Line() {
mString = new std::string("foo_bar");
}
Line::~Line() {
delete mString;
}
En realidad, es mucho más riesgoso de usar que el siguiente:
class Line {
public:
Line();
std::string mString;
};
Line::Line() {
mString = "foo_bar";
// note: there is a cleaner way to write this.
}
La razón es que std::string
define correctamente un constructor de copia. Considere el siguiente programa:
int main ()
{
Line l1;
Line l2 = l1;
}
Al usar la versión original, este programa probablemente fallará, ya que usa delete
la misma cadena dos veces. Usando la versión modificada, cada Line
instancia tendrá su propia instancia de cadena , cada una con su propia memoria y ambas se liberarán al final del programa.
Otras notas
El uso extensivo de RAII se considera una mejor práctica en C++ por todas las razones anteriores. Sin embargo, existe un beneficio adicional que no es inmediatamente obvio. Básicamente, es mejor que la suma de sus partes. Todo el mecanismo lo compone . Se escala.
Si usa la Line
clase como bloque de construcción:
class Table
{
Line borders[4];
};
Entonces
int main ()
{
Table table;
}
asigna cuatro std::string
instancias, cuatro Line
instancias, una Table
instancia y todo el contenido de la cadena y todo se libera automáticamente .
Porque la pila es más rápida y a prueba de fugas
En C++, solo se necesita una instrucción para asignar espacio (en la pila) para cada objeto de alcance local en una función determinada, y es imposible perder parte de esa memoria. Ese comentario pretendía (o debería haber pretendido) decir algo como "usa la pila y no el montón".
La razón es complicada.
En primer lugar, C++ no se recolecta como basura. Por lo tanto, por cada novedad, debe haber una eliminación correspondiente. Si no realiza esta eliminación, tendrá una pérdida de memoria. Ahora, para un caso simple como este:
std::string *someString = new std::string(...);
//Do stuff
delete someString;
Esto es simple. Pero, ¿qué sucede si "Hacer cosas" genera una excepción? Vaya: pérdida de memoria. ¿Qué sucede si "Hacer cosas" se publica return
antes de tiempo? Vaya: pérdida de memoria.
Y esto es para el caso más simple . Si le devuelves esa cadena a alguien, ahora tendrá que eliminarla. Y si lo pasan como argumento, ¿la persona que lo recibe necesita borrarlo? ¿Cuándo deberían borrarlo?
O simplemente puedes hacer esto:
std::string someString(...);
//Do stuff
No delete
. El objeto se creó en la "pila" y será destruido una vez que salga del alcance. Incluso puedes devolver el objeto, transfiriendo así su contenido a la función que llama. Puede pasar el objeto a funciones (normalmente como referencia o referencia constante: void SomeFunc(std::string &iCanModifyThis, const std::string &iCantModifyThis)
. Y así sucesivamente).
Todo sin new
y delete
. No hay duda de quién es el propietario de la memoria o quién es responsable de borrarla. Si lo haces:
std::string someString(...);
std::string otherString;
otherString = someString;
Se entiende que otherString
dispone de copia de los datos de someString
. No es un puntero; es un objeto separado. Puede que tengan el mismo contenido, pero puedes cambiar uno sin afectar al otro:
someString += "More text.";
if(otherString == someString) { /*Will never get here */ }
¿Ves la idea?
Los objetos creados por new
deben eventualmente delete
eliminarse para que no se filtren. No se llamará al destructor, no se liberará la memoria, todo. Como C++ no tiene recolección de basura, es un problema.
Los objetos creados por valor (es decir, en la pila) mueren automáticamente cuando salen del alcance. El compilador inserta la llamada al destructor y la memoria se libera automáticamente al regresar la función.
Los punteros inteligentes como unique_ptr
, shared_ptr
resuelven el problema de las referencias pendientes, pero requieren disciplina de codificación y tienen otros problemas potenciales (copiabilidad, bucles de referencia, etc.).
Además, en escenarios con muchos subprocesos, new
existe un punto de discordia entre subprocesos; puede haber un impacto en el rendimiento por el uso excesivo new
. La creación de objetos de pila es, por definición, local de subprocesos, ya que cada subproceso tiene su propia pila.
La desventaja de los objetos de valor es que mueren una vez que regresa la función host; no puede pasar una referencia a ellos a la persona que llama, solo copiándolos, devolviéndolos o moviéndolos por valor.
- C++ no emplea ningún administrador de memoria por sí solo. Otros lenguajes como C# y Java tienen un recolector de basura para manejar la memoria.
- Las implementaciones de C++ suelen utilizar rutinas del sistema operativo para asignar la memoria y demasiadas nuevas/eliminaciones podrían fragmentar la memoria disponible.
- Con cualquier aplicación, si la memoria se utiliza con frecuencia, es recomendable preasignarla y liberarla cuando no sea necesaria.
- Una gestión inadecuada de la memoria podría provocar pérdidas de memoria y es realmente difícil de rastrear. Entonces, usar objetos de pila dentro del alcance de la función es una técnica probada.
- La desventaja de usar objetos de pila es que crea múltiples copias de objetos al regresar, pasar a funciones, etc. Sin embargo, los compiladores inteligentes conocen muy bien estas situaciones y se han optimizado bien para el rendimiento.
- Es realmente tedioso en C++ si la memoria se asigna y libera en dos lugares diferentes. La responsabilidad del lanzamiento es siempre una cuestión y, sobre todo, confiamos en algunos punteros, objetos de pila (el máximo posible) y técnicas comúnmente accesibles como auto_ptr ( objetos RAII ).
- Lo mejor es que tienes control sobre la memoria y lo peor es que no tendrás ningún control sobre la memoria si empleamos una gestión de memoria inadecuada para la aplicación. Los fallos causados por daños en la memoria son los más desagradables y difíciles de rastrear.