C ++: ¿Punteros inteligentes, punteros sin formato, sin punteros? [cerrado]

48

Dentro del alcance del desarrollo de juegos en C ++, ¿cuáles son sus patrones preferidos con respecto al uso de punteros (ya sea ninguno, sin formato, con alcance, compartido o de otro modo entre inteligente y tonto)?

Usted puede considerar

  • propiedad del objeto
  • facilidad de uso
  • política de copia
  • gastos generales
  • referencias cíclicas
  • plataforma objetivo
  • utilizar con contenedores
jmp97
fuente

Respuestas:

32

Después de haber probado varios enfoques, hoy me encuentro alineado con la Guía de estilo de Google C ++ :

Si realmente necesita semántica de puntero, scoped_ptr es excelente. Solo debe usar std :: tr1 :: shared_ptr en condiciones muy específicas, como cuando los contenedores STL deben contener objetos. Nunca debe usar auto_ptr. [...]

En términos generales, preferimos que diseñemos código con propiedad clara del objeto. La propiedad más clara del objeto se obtiene usando un objeto directamente como un campo o variable local, sin usar punteros en absoluto. [..]

Aunque no se recomiendan, los punteros contados por referencia son a veces la forma más simple y elegante de resolver un problema.

jmp97
fuente
14
Hoy, es posible que desee utilizar std :: unique_ptr en lugar de scoped_ptr.
Klaim
24

También sigo el tren de pensamiento de "propiedad fuerte". Me gusta delinear claramente que "esta clase posee este miembro" cuando es apropiado.

Raramente uso shared_ptr. Si lo hago, hago un uso generoso weak_ptrsiempre que puedo para poder tratarlo como un identificador del objeto en lugar de aumentar el recuento de referencias.

Yo uso scoped_ptrtodo el lugar. Muestra una propiedad obvia. La única razón por la que no solo hago objetos como ese miembro es porque puedes reenviarlos si están en un scoped_ptr.

Si necesito una lista de objetos, los uso ptr_vector. Es más eficiente y tiene menos efectos secundarios que el uso vector<shared_ptr>. Creo que es posible que no pueda reenviar la declaración del tipo en el ptr_vector (ha pasado un tiempo), pero la semántica hace que valga la pena en mi opinión. Básicamente, si elimina un objeto de la lista, se elimina automáticamente. Esto también muestra una propiedad obvia.

Si necesito referencia a algo, trato de hacer una referencia en lugar de un puntero desnudo. A veces esto no es práctico (es decir, cada vez que necesita una referencia después de que se construye el objeto). De cualquier manera, las referencias muestran obviamente que no es el propietario del objeto, y si sigue la semántica de puntero compartido en cualquier otro lugar, los punteros desnudos generalmente no causan ninguna confusión adicional (especialmente si sigue una regla de "no eliminar manualmente") .

Con este método, un juego de iPhone en el que trabajé solo podía tener una sola deletellamada, y eso fue en el puente Obj-C a C ++ que escribí.

En general, opino que la gestión de la memoria es demasiado importante para dejarla a los humanos. Si puede automatizar la eliminación, debería hacerlo. Si la sobrecarga de shared_ptr es demasiado costosa en el tiempo de ejecución (suponiendo que desactivó el soporte de subprocesos, etc.), probablemente debería estar usando otra cosa (es decir, un patrón de depósito) para reducir sus asignaciones dinámicas.

Tetrad
fuente
1
Excelente resumen ¿En realidad te refieres a shared_ptr en lugar de mencionar a smart_ptr?
jmp97
Sí, quise decir shared_ptr. Lo arreglaré
Tetrad
10

Use la herramienta adecuada para el trabajo.

Si su programa puede lanzar excepciones, asegúrese de que su código tenga en cuenta las excepciones. Usar punteros inteligentes, RAII y evitar la construcción en 2 fases son buenos puntos de partida.

Si tiene referencias cíclicas sin una semántica de propiedad clara, puede considerar usar una biblioteca de recolección de basura o refactorizar su diseño.

Las buenas bibliotecas le permitirán codificar el concepto, no el tipo, por lo que no debería importar en la mayoría de los casos qué tipo de puntero está utilizando más allá de los problemas de gestión de recursos.

Si está trabajando en un entorno de subprocesos múltiples, asegúrese de comprender si su objeto es potencialmente compartido entre subprocesos. Una de las razones principales para considerar el uso de boost :: shared_ptr o std :: tr1 :: shared_ptr es porque utiliza un recuento de referencia seguro para subprocesos.

Si le preocupa la asignación separada de los recuentos de referencia, hay muchas maneras de evitar esto. Usando la biblioteca boost :: shared_ptr, puede asignar en conjunto los contadores de referencia o usar boost :: make_shared (mi preferencia) que asigna el objeto y el recuento de referencia en una sola asignación, aliviando así la mayoría de las preocupaciones de pérdida de caché que tienen las personas. Puede evitar el impacto de rendimiento de actualizar el recuento de referencias en el código crítico de rendimiento manteniendo una referencia al objeto en el nivel más alto y pasando referencias directas al objeto.

Si necesita una propiedad compartida pero no quiere pagar el costo del recuento de referencias o la recolección de basura, considere usar objetos inmutables o una copia en el idioma de escritura.

Tenga en cuenta que, de lejos, sus mayores logros de rendimiento serán a nivel de arquitectura, seguidos de un nivel de algoritmo, y si bien estas preocupaciones de bajo nivel son muy importantes, deben abordarse solo después de que haya abordado los principales problemas. Si está lidiando con problemas de rendimiento en el nivel de errores de caché, entonces tiene una gran cantidad de problemas que también debe tener en cuenta, como el intercambio falso que no tiene nada que ver con los punteros por decir.

Si está utilizando punteros inteligentes solo para compartir recursos como texturas o modelos, considere una biblioteca más especializada como Boost.Flyweight.

Una vez que se adopte el nuevo estándar, la semántica de movimiento, las referencias de valor y el reenvío perfecto harán que trabajar con objetos y contenedores caros sea mucho más fácil y más eficiente. Hasta entonces, no almacene punteros con semántica de copia destructiva, como auto_ptr o unique_ptr, en un Contenedor (el concepto estándar). Considere usar la biblioteca Boost.Pointer Container o almacenar punteros inteligentes de propiedad compartida en Contenedores. En el código crítico de rendimiento, puede considerar evitar ambos a favor de contenedores intrusivos como los de Boost.Intrusive.

La plataforma objetivo realmente no debería influir demasiado en su decisión. Los dispositivos integrados, teléfonos inteligentes, teléfonos tontos, PC y consolas pueden ejecutar el código perfectamente. Los requisitos del proyecto, como presupuestos estrictos de memoria o ninguna asignación dinámica alguna vez / después de la carga, son preocupaciones más válidas y deberían influir en sus elecciones.

Jaeger
fuente
3
El manejo de excepciones en las consolas puede ser un poco dudoso: el XDK en particular es una especie de excepción hostil.
Crashworks
1
La plataforma de destino realmente debería influir en su diseño. El hardware que transforma sus datos a veces puede tener grandes influencias en su código fuente. La arquitectura PS3 es un ejemplo concreto en el que realmente necesita llevar el hardware al diseño de sus recursos y gestión de memoria, así como a su procesador.
Simon
No estoy de acuerdo solo un poco, específicamente con respecto a GC. La mayoría de las veces, las referencias cíclicas no son un problema para los esquemas contados de referencia. En general, estos problemas de propiedad cíclica surgen porque la gente no pensaba correctamente sobre la propiedad de los objetos. El hecho de que un objeto necesite apuntar a algo no significa que deba poseer ese puntero. El ejemplo comúnmente citado son los punteros hacia atrás en los árboles, pero el padre del puntero en un árbol puede ser un puntero en bruto sin sacrificar la seguridad.
Tim Seguine
4

Si usa C ++ 0x, use std::unique_ptr<T>.

No tiene sobrecarga de rendimiento, a diferencia de lo std::shared_ptr<T>que tiene sobrecarga de conteo de referencia. Un unique_ptr posee su puntero, y puede transferir la propiedad con la semántica de movimiento de C ++ 0x . No puedes copiarlos, solo moverlos.

También se puede usar en contenedores, por ejemplo std::vector<std::unique_ptr<T>>, que es compatible con binarios e idéntico en rendimiento std::vector<T*>, pero no perderá memoria si borra elementos o borra el vector. Esto también tiene una mejor compatibilidad con algoritmos STL que ptr_vector.

OMI para muchos propósitos, este es un contenedor ideal: acceso aleatorio, excepción segura, evita pérdidas de memoria, baja sobrecarga para la reasignación de vectores (simplemente baraja los punteros detrás de escena). Muy útil para muchos propósitos.

AshleysBrain
fuente
3

Es una buena práctica documentar qué clases poseen qué punteros. Preferiblemente, solo usa objetos normales y no punteros siempre que puede.

Sin embargo, cuando necesita hacer un seguimiento de los recursos, pasar punteros es la única opción. Hay algunos casos:

  • Obtiene el puntero de otro lugar, pero no lo gestiona: solo use un puntero normal y documente para que no haya codificador después de que intente eliminarlo.
  • Obtiene el puntero de otro lugar y lo rastrea: use un scoped_ptr.
  • Obtiene el puntero de otro lugar y lo rastrea, pero necesita un método especial para eliminarlo: use shared_ptr con un método de eliminación personalizado.
  • Necesita el puntero en un contenedor STL: se copiará, por lo que necesita boost :: shared_ptr.
  • Muchas clases comparten el puntero, y no está claro quién lo eliminará: shared_ptr (el caso anterior es en realidad un caso especial de este punto).
  • Usted crea el puntero usted mismo y solo lo necesita: si realmente no puede usar un objeto normal: scoped_ptr.
  • Crea el puntero y lo compartirá con otras clases: shared_ptr.
  • Usted crea el puntero y lo pasa: ¡use un puntero normal y documente su interfaz para que el nuevo propietario sepa que debe administrar el recurso él mismo!

Creo que eso cubre más o menos cómo administro mis recursos en este momento. El costo de memoria de un puntero como shared_ptr es generalmente el doble del costo de memoria de un puntero normal. No creo que esta sobrecarga sea demasiado grande, pero si tienes pocos recursos, deberías considerar diseñar tu juego para reducir la cantidad de punteros inteligentes. En otros casos, solo diseño buenos principios como las viñetas anteriores y el generador de perfiles me dirá dónde necesitaré más velocidad.

Nef
fuente
1

Cuando se trata específicamente de los indicadores de impulso, creo que deberían evitarse siempre que su implementación no sea exactamente lo que necesita. Tienen un costo que es más grande de lo que cualquiera esperaría inicialmente. Proporcionan una interfaz que le permite omitir partes vitales e importantes de su memoria y gestión de recursos.

Cuando se trata de cualquier desarrollo de software, creo que es importante pensar en sus datos. Es muy importante cómo se representan sus datos en la memoria. La razón de esto es que la velocidad de la CPU ha aumentado a un ritmo mucho mayor que el tiempo de acceso a la memoria. Esto a menudo hace que las memorias caché sean el principal cuello de botella de la mayoría de los juegos de computadora modernos. Al tener sus datos alineados linealmente en la memoria de acuerdo con el orden de acceso, es mucho más amigable con el caché. Este tipo de soluciones a menudo conducen a diseños más limpios, un código más simple y un código definitivamente más fácil de depurar. Los punteros inteligentes conducen fácilmente a asignaciones frecuentes de memoria dinámica de recursos, lo que hace que se dispersen por toda la memoria.

Esta no es una optimización prematura, es una decisión saludable que puede y debe tomarse lo antes posible. Es una cuestión de comprensión arquitectónica del hardware en el que se ejecutará su software y es importante.

Editar: Hay algunas cosas a tener en cuenta con respecto al rendimiento de los punteros compartidos:

  • El contador de referencia está asignado en el montón.
  • Si utiliza la seguridad de subprocesos habilitada, el recuento de referencias se realiza mediante operaciones enclavadas.
  • Pasar el puntero por valor modifica el recuento de referencia, lo que significa que las operaciones entrelazadas probablemente usan acceso aleatorio en la memoria (bloqueos + probable pérdida de caché).
Simon
fuente
2
Me perdiste en "evitado a toda costa". Luego, continúa describiendo un tipo de optimización que rara vez es una preocupación para los juegos del mundo real. La mayoría del desarrollo de juegos se caracteriza por problemas de desarrollo (retrasos, errores, jugabilidad, etc.), no por la falta de rendimiento de la memoria caché de la CPU. Así que estoy totalmente en desacuerdo con la idea de que este consejo no es una optimización prematura.
kevin42
2
Tengo que estar de acuerdo con el diseño inicial del diseño de datos. Es importante obtener cualquier rendimiento de una consola / dispositivo móvil moderno y es algo que nunca debe pasarse por alto.
Olly
1
Este es un problema que he visto en uno de los estudios AAA en los que he estado trabajando. También puedes escuchar al Arquitecto Jefe de Insomniac Games, Mike Acton. No digo que boost sea una mala biblioteca, no solo es adecuada para juegos de alto rendimiento.
Simon
1
@ kevin42: la coherencia de caché es probablemente la principal fuente de optimizaciones de bajo nivel en el desarrollo de juegos hoy en día. @Simon: La mayoría de las implementaciones de shared_ptr evitan bloqueos en cualquier plataforma que admita comparar e intercambiar, que incluye PC con Linux y Windows, y creo que incluye Xbox.
1
@ Joe Wreschnig: Eso es cierto, la pérdida de caché todavía es muy probable, aunque causa la inicialización de un puntero compartido (copiar, crear desde un puntero débil, etc.). Un error de caché L2 en las PC modernas es como 200 ciclos y en el PPC (xbox360 / ps3) es más alto. Con un juego intenso, puede tener hasta 1000 objetos de juego, dado que cada objeto del juego puede tener bastantes recursos, estamos analizando problemas en los que su escala es una preocupación importante. Esto probablemente causará problemas al final de un ciclo de desarrollo (cuando golpees la gran cantidad de objetos del juego).
Simon
0

Tiendo a usar punteros inteligentes en todas partes. No estoy seguro de si esta es una idea totalmente buena, pero soy flojo y no puedo ver ningún inconveniente real [excepto si quisiera hacer algo de aritmética de puntero estilo C]. Uso boost :: shared_ptr porque sé que puedo copiarlo: si dos entidades comparten una imagen, si una muere, la otra no debería perder la imagen también.

La desventaja de esto es que si un objeto elimina algo a lo que señala y posee, pero algo más también lo señala, entonces no se elimina.

El pato comunista
fuente
1
También he estado usando share_ptr en casi todas partes, pero hoy trato de pensar si realmente necesito o no la propiedad compartida de algún dato. De lo contrario, podría ser razonable hacer que esos datos sean un miembro no puntero a la estructura de datos primaria. Me parece que la propiedad clara simplifica los diseños.
jmp97
0

Los beneficios de la gestión de la memoria y la documentación proporcionada por buenos punteros inteligentes significan que los uso regularmente. Sin embargo, cuando el generador de perfiles se abre paso y me dice que un uso particular me está costando, volveré a una gestión de puntero más neolítica.

tenpn
fuente
0

Soy viejo, oldskool y contador de ciclos. En mi propio trabajo, uso punteros sin procesar y sin asignaciones dinámicas en tiempo de ejecución (excepto los grupos en sí). Todo está agrupado, y la propiedad es muy estricta y nunca transferible, si realmente es necesario, escribo un asignador de bloque pequeño personalizado. Me aseguro de que haya un estado durante el juego para que cada grupo se elimine. Cuando las cosas se ponen difíciles, envuelvo los objetos en los mangos para poder reubicarlos, pero prefiero no hacerlo. Los contenedores son personalizados y huesos extremadamente desnudos. Tampoco reutilizo el código.
Si bien nunca discutiría la virtud de todos los punteros inteligentes, contenedores e iteradores y demás, soy conocido por poder codificar extremadamente rápido (y razonablemente confiable, aunque no es aconsejable que otros salten a mi código por razones algo obvias, como ataques al corazón y pesadillas perpetuas).

En el trabajo, por supuesto, todo es diferente, a menos que esté haciendo prototipos, que afortunadamente puedo hacer mucho.

Kaj
fuente
0

Sin embargo, casi ninguna es una respuesta extraña, y probablemente ni mucho menos adecuada para todos.

Pero he encontrado que es mucho más útil en mi caso personal almacenar todas las instancias de un tipo particular en una secuencia central de acceso aleatorio (seguro para subprocesos) y, en cambio, trabajar con índices de 32 bits (direcciones relativas, es decir) , en lugar de punteros absolutos.

Para comenzar:

  1. Reduce a la mitad los requisitos de memoria del puntero analógico en plataformas de 64 bits. Hasta ahora nunca he necesitado más de ~ 4.29 mil millones de instancias de un tipo de datos en particular.
  2. Se asegura de que todas las instancias de un tipo en particular Tnunca se dispersen demasiado en la memoria. Eso tiende a reducir las pérdidas de caché para todo tipo de patrones de acceso, incluso atravesando estructuras vinculadas como árboles si los nodos se vinculan entre sí mediante índices en lugar de punteros.
  3. Los datos paralelos se vuelven fáciles de asociar utilizando matrices paralelas baratas (o matrices dispersas) en lugar de árboles o tablas hash.
  4. Las intersecciones establecidas se pueden encontrar en tiempo lineal o mejor usando, por ejemplo, un conjunto de bits paralelo.
  5. Podemos ordenar los índices por radix y obtener un patrón de acceso secuencial muy amigable con la caché.
  6. Podemos hacer un seguimiento de cuántas instancias se ha asignado un tipo de datos en particular.
  7. Minimiza la cantidad de lugares que tienen que lidiar con cosas como la seguridad de excepción, si te importa ese tipo de cosas.

Dicho esto, la conveniencia es una desventaja, así como la seguridad de tipo. No podemos acceder a una instancia de Tsin tener acceso tanto al contenedor como al índice. Y un viejo int32_tno nos dice nada sobre el tipo de datos al que se refiere, por lo que no hay seguridad de tipo. Podríamos intentar acceder accidentalmente a Barutilizando un índice para Foo. Para mitigar el segundo problema, a menudo hago este tipo de cosas:

struct FooIndex
{
    int32_t index;
};

Lo que parece un poco tonto, pero me devuelve la seguridad de tipo para que las personas no puedan intentar acceder accidentalmente a Bartravés de un índice Foosin un error del compilador. Por conveniencia, acepto las pequeñas molestias.

Otra cosa que podría ser un gran inconveniente para las personas es que no puedo usar el polimorfismo basado en la herencia de estilo OOP, ya que eso requeriría un puntero base que pueda apuntar a todo tipo de subtipos diferentes con diferentes requisitos de tamaño y alineación. Pero no uso mucho la herencia en estos días, prefiero el enfoque ECS.

En cuanto a shared_ptr, trato de no usarlo tanto. La mayoría de las veces no creo que tenga sentido compartir la propiedad, y hacerlo al azar puede conducir a fugas lógicas. A menudo, al menos en un nivel superior, una cosa tiende a pertenecer a una sola. Donde a menudo me pareció tentador usar shared_ptrera extender la vida útil de un objeto en lugares que realmente no trataban tanto con la propiedad, como solo una función local en un hilo para asegurarse de que el objeto no se destruya antes de que el hilo termine usándolo

Para abordar ese problema, en lugar de usar shared_ptrGC o algo así, a menudo prefiero las tareas de corta duración que se ejecutan desde un grupo de subprocesos, y hago que si ese subproceso solicite destruir un objeto, la destrucción real se difiera a una caja fuerte momento en que el sistema puede garantizar que ningún hilo necesite acceder a dicho tipo de objeto.

Todavía a veces termino usando el recuento de ref, pero lo trato como una estrategia de último recurso. Y hay algunos casos en los que realmente tiene sentido compartir la propiedad, como la implementación de una estructura de datos persistente, y creo que tiene mucho sentido llegar de shared_ptrinmediato.

De todos modos, en su mayoría uso índices, y uso punteros crudos e inteligentes con moderación. Me gustan los índices y el tipo de puertas que se abren cuando sabes que tus objetos se almacenan de forma contigua y no se encuentran dispersos en el espacio de la memoria.

user77245
fuente