"La optimización temprana es la raíz de todo mal"
Creo que todos podemos estar de acuerdo. Y me esfuerzo mucho para evitar hacer eso.
Pero recientemente me he estado preguntando sobre la práctica de pasar parámetros por referencia constante en lugar de por valor . Me han enseñado / aprendido que los argumentos de funciones no triviales (es decir, la mayoría de los tipos no primitivos) deben pasarse preferiblemente por referencia constante ; bastantes libros que he leído recomiendan esto como una "mejor práctica".
Aún así, no puedo evitar preguntarme: los compiladores modernos y las nuevas características del lenguaje pueden hacer maravillas, por lo que el conocimiento que he aprendido puede estar desactualizado, y nunca me molesté en hacer un perfil si hay diferencias de rendimiento entre
void fooByValue(SomeDataStruct data);
y
void fooByReference(const SomeDataStruct& data);
¿Es la práctica que he aprendido - pasar referencias constantes (por defecto para tipos no triviales) - optimización prematura?
fuente
Respuestas:
La "optimización prematura" no se trata de utilizar optimizaciones temprano . Se trata de optimizar antes de que se comprenda el problema, antes de que se comprenda el tiempo de ejecución, y a menudo hacer que el código sea menos legible y menos mantenible para obtener resultados dudosos.
Usar "const &" en lugar de pasar un objeto por valor es una optimización bien entendida, con efectos bien entendidos en el tiempo de ejecución, prácticamente sin esfuerzo y sin ningún efecto negativo sobre la legibilidad y la capacidad de mantenimiento. Realmente mejora ambos, porque me dice que una llamada no modificará el objeto pasado. Así que agregar "const &" justo cuando escribe el código NO ES PREMATURO.
fuente
const&
, por lo que creo que la pregunta es bastante sensata.const& foo
dice que la función no modificará foo, por lo que la persona que llama está a salvo. Pero un valor copiado dice que ningún otro hilo puede cambiar foo, por lo que la persona que llama está a salvo. ¿Derecho? Entonces, en una aplicación multiproceso, la respuesta depende de la corrección, no de la optimización.TL; DR: Pasar por referencia constante sigue siendo una buena idea en C ++, considerando todo. No es una optimización prematura.
TL; DR2: La mayoría de los adagios no tienen sentido, hasta que lo hacen.
Objetivo
Esta respuesta solo intenta extender un poco el elemento vinculado en las Pautas principales de C ++ (mencionado por primera vez en el comentario de amon).
Esta respuesta no trata de abordar el tema de cómo pensar y aplicar adecuadamente los diversos adagios que circularon ampliamente dentro de los círculos de programadores, especialmente el tema de la conciliación entre conclusiones o pruebas en conflicto.
Aplicabilidad
Esta respuesta se aplica solo a las llamadas a funciones (ámbitos anidados no desmontables en el mismo hilo).
(Nota al margen .) Cuando las cosas pasables pueden escapar del alcance (es decir, tener una vida útil que potencialmente excede el alcance externo), se vuelve más importante satisfacer la necesidad de la aplicación de la administración de la vida útil del objeto antes que cualquier otra cosa. Por lo general, esto requiere el uso de referencias que también sean capaces de administrar de por vida, como los punteros inteligentes. Una alternativa podría ser usar un gerente. Tenga en cuenta que lambda es un tipo de alcance desmontable; Las capturas lambda se comportan como si tuvieran un alcance de objeto. Por lo tanto, tenga cuidado con las capturas lambda. También tenga cuidado con la forma en que se pasa la lambda, ya sea por copia o por referencia.
Cuando pasar por valor
Para valores que son escalares (primitivas estándar que se ajustan dentro de un registro de máquina y tienen un valor semántico) para los cuales no hay necesidad de comunicación por mutabilidad (referencia compartida), pase por valor.
Para situaciones en las que la persona que llama requiere una clonación de un objeto o agregado, pase por valor, en el que la copia de la persona que llama satisface la necesidad de un objeto clonado.
Cuándo pasar por referencia, etc.
para todas las demás situaciones, pase por punteros, referencias, punteros inteligentes, manijas (ver: modismo de manijas-cuerpo), etc. Siempre que se siga este consejo, aplique el principio de corrección constante como de costumbre.
Las cosas (agregados, objetos, matrices, estructuras de datos) que son suficientemente grandes en la huella de la memoria siempre deben diseñarse para facilitar el paso por referencia, por razones de rendimiento. Este consejo definitivamente se aplica cuando tiene cientos de bytes o más. Este consejo es dudoso cuando tiene decenas de bytes.
Paradigmas inusuales
Existen paradigmas de programación de propósito especial que son pesados por intención. Por ejemplo, procesamiento de cadenas, serialización, comunicación de red, aislamiento, envoltura de bibliotecas de terceros, comunicación entre procesos de memoria compartida, etc. En estas áreas de aplicación o paradigmas de programación, los datos se copian de estructuras a estructuras o, a veces, se vuelven a empaquetar en conjuntos de bytes.
Cómo afecta la especificación del lenguaje a esta respuesta, antes de considerar la optimización.
Sub-TL; DR Propagar una referencia no debe invocar ningún código; pasar por const-reference satisface este criterio. Sin embargo, todos los demás idiomas satisfacen este criterio sin esfuerzo.
(Se recomienda a los programadores novatos de C ++ que omitan esta sección por completo).
(El comienzo de esta sección está inspirado en parte por la respuesta de gnasher729. Sin embargo, se llega a una conclusión diferente).
C ++ permite constructores de copia definidos por el usuario y operadores de asignación.
(Esta es (fue) una elección audaz que es (fue) a la vez sorprendente y lamentable. Definitivamente es una divergencia de la norma aceptable actual en el diseño del lenguaje).
Incluso si el programador de C ++ no define uno, el compilador de C ++ debe generar dichos métodos basados en los principios del lenguaje y luego determinar si es necesario ejecutar un código adicional
memcpy
. Por ejemplo, unclass
/struct
que contiene unstd::vector
miembro debe tener un constructor de copia y un operador de asignación que no sea trivial.En otros lenguajes, se desaconsejan los constructores de copias y la clonación de objetos (excepto donde sea absolutamente necesario y / o significativo para la semántica de la aplicación), porque los objetos tienen semántica de referencia, por diseño de lenguaje. Estos lenguajes generalmente tendrán un mecanismo de recolección de basura que se basa en la accesibilidad en lugar de la propiedad basada en el alcance o el conteo de referencias.
Cuando se pasa una referencia o puntero (incluida la referencia constante) en C ++ (o C), el programador se asegura de que no se ejecutará ningún código especial (funciones definidas por el usuario o generadas por el compilador), aparte de la propagación del valor de la dirección (referencia o puntero). Esta es una claridad de comportamiento con la que los programadores de C ++ se sienten cómodos.
Sin embargo, el contexto es que el lenguaje C ++ es innecesariamente complicado, de modo que esta claridad de comportamiento es como un oasis (un hábitat sobrevivible) en algún lugar alrededor de una zona nuclear.
Para agregar más bendiciones (o insultos), C ++ introduce referencias universales (valores r) para facilitar los operadores de movimiento definidos por el usuario (constructores de movimiento y operadores de asignación de movimiento) con un buen rendimiento. Esto beneficia un caso de uso altamente relevante (el movimiento (transferencia) de objetos de una instancia a otra), al reducir la necesidad de copia y clonación profunda. Sin embargo, en otros idiomas, es ilógico hablar de tal movimiento de objetos.
(Sección fuera de tema) Una sección dedicada a un artículo, "¿Quieres velocidad? ¡Pasa por valor!" escrito en circa 2009.
Ese artículo fue escrito en 2009 y explica la justificación del diseño para el valor r en C ++. Ese artículo presenta un contraargumento válido a mi conclusión en la sección anterior. Sin embargo, el ejemplo de código del artículo y el reclamo de rendimiento han sido refutados durante mucho tiempo.
Sub-TL; DR El diseño de la semántica de valor r en C ++ permite una semántica sorprendentemente elegante del lado del usuario en una
Sort
función, por ejemplo. Este elegante es imposible de modelar (imitar) en otros idiomas.Una función de clasificación se aplica a una estructura de datos completa. Como se mencionó anteriormente, sería lento si hay muchas copias involucradas. Como una optimización del rendimiento (que es prácticamente relevante), una función de clasificación está diseñada para ser destructiva en bastantes lenguajes distintos de C ++. Destructivo significa que la estructura de datos objetivo se modifica para lograr el objetivo de clasificación.
En C ++, el usuario puede elegir llamar a una de las dos implementaciones: una destructiva con mejor rendimiento, o una normal que no modifica la entrada. (La plantilla se omite por brevedad).
Además de la clasificación, esta elegancia también es útil en la implementación del algoritmo de búsqueda mediana destructiva en una matriz (inicialmente sin clasificar), mediante particiones recursivas.
Sin embargo, tenga en cuenta que, la mayoría de los idiomas aplicarían un enfoque de árbol de búsqueda binario equilibrado a la ordenación, en lugar de aplicar un algoritmo de ordenación destructivo a las matrices. Por lo tanto, la relevancia práctica de esta técnica no es tan alta como parece.
Cómo la optimización del compilador afecta esta respuesta
Cuando se aplica la alineación (y también la optimización de todo el programa / optimización del tiempo de enlace) en varios niveles de llamadas a funciones, el compilador puede ver (a veces exhaustivamente) el flujo de datos. Cuando esto sucede, el compilador puede aplicar muchas optimizaciones, algunas de las cuales pueden eliminar la creación de objetos completos en la memoria. Por lo general, cuando se aplica esta situación, no importa si los parámetros se pasan por valor o por referencia constante, porque el compilador puede analizar exhaustivamente.
Sin embargo, si la función de nivel inferior llama a algo que está más allá del análisis (por ejemplo, algo en una biblioteca diferente fuera de la compilación, o un gráfico de llamadas que es simplemente demasiado complicado), entonces el compilador debe optimizar a la defensiva.
Los objetos mayores que un valor de registro de máquina pueden copiarse mediante instrucciones explícitas de carga / almacenamiento de memoria, o mediante una llamada a la
memcpy
función venerable . En algunas plataformas, el compilador genera instrucciones SIMD para moverse entre dos ubicaciones de memoria, cada instrucción mueve decenas de bytes (16 o 32).Discusión sobre el tema de la verbosidad o el desorden visual.
Los programadores de C ++ están acostumbrados a esto, es decir, mientras un programador no odie a C ++, la sobrecarga de escribir o leer referencias constantes en el código fuente no es horrible.
Los análisis de costo-beneficio podrían haberse realizado muchas veces antes. No sé si hay algunos científicos que deberían citarse. Supongo que la mayoría de los análisis serían no científicos o no reproducibles.
Esto es lo que imagino (sin pruebas o referencias creíbles) ...
fuente
Esto no aconseja a los programadores que usen las técnicas más lentas disponibles. Se trata de enfocarse en la claridad al escribir programas. A menudo, la claridad y la eficiencia son una compensación: si debe elegir solo una, elija la claridad. Pero si puede lograr ambos fácilmente, no es necesario paralizar la claridad (como indicar que algo es constante) solo para evitar la eficiencia.
fuente
Pasar por ([const] [rvalue] reference) | (value) debe ser sobre la intención y las promesas hechas por la interfaz. No tiene nada que ver con el rendimiento.
La regla general de Richy:
fuente
Teóricamente, la respuesta debería ser sí. Y, de hecho, es cierto en algunas ocasiones: de hecho, pasar por referencia constante en lugar de simplemente pasar un valor puede ser una pesimización, incluso en los casos en que el valor pasado es demasiado grande para caber en un solo registrarse (o la mayoría de las otras heurísticas que las personas intentan usar para determinar cuándo pasar por valor o no). Hace años, David Abrahams escribió un artículo llamado "¿Quieres velocidad? ¡Pasa por valor!" cubriendo algunos de estos casos. Ya no es fácil de encontrar, pero si puedes desenterrar una copia, vale la pena leerla (IMO).
Sin embargo, en el caso específico de pasar por referencia constante, diría que el idioma está tan bien establecido que la situación está más o menos invertida: a menos que sepa que el tipo será
char
/short
/int
/long
, la gente espera verlo pasar por constante referencia por defecto, por lo que probablemente sea mejor aceptarlo a menos que tenga una razón bastante específica para hacer lo contrario.fuente