¿Pasar argumentos como referencias constantes es una optimización prematura?

20

"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?

CharonX
fuente
1
Ver también: F.call en las Pautas principales de C ++ para una discusión de varias estrategias de paso de parámetros.
amon
1
@DocBrown La respuesta aceptada a esa pregunta se refiere al principio de menor asombro , que también puede aplicarse aquí (es decir, usar referencias constantes es el estándar de la industria, etc., etc.). Dicho esto, no estoy de acuerdo con que la pregunta sea un duplicado: la pregunta a la que se refiere pregunta si es una mala práctica (generalmente) confiar en la optimización del compilador. Esta pregunta es inversa: ¿pasar referencias constantes es una optimización (prematura)?
CharonX
@CharonX: si uno puede confiar en la optimización del compilador aquí, la respuesta a su pregunta es claramente "sí, la optimización manual no es necesaria, es prematura". Si uno no puede confiar en él (quizás porque no sabe de antemano qué compiladores se usarán alguna vez para el código), la respuesta es "para objetos más grandes, probablemente no sea prematuro". Entonces, incluso si esas dos preguntas no son literalmente iguales, en mi humilde opinión, parecen ser lo suficientemente parecidas como para unirlas como duplicados.
Doc Brown
1
@DocBrown: Entonces, antes de que puedas declararlo engañado, señala dónde en la pregunta dice que el compilador podrá "optimizar" eso.
Deduplicador

Respuestas:

49

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.

gnasher729
fuente
2
Estoy de acuerdo con la parte "prácticamente sin esfuerzo" de su respuesta. Pero la optimización prematura se trata principalmente de la optimización antes de que tenga un impacto notable y medido en el rendimiento. Y no creo que la mayoría de los programadores de C ++ (que me incluye a mí mismo) realicen mediciones antes de usar const&, por lo que creo que la pregunta es bastante sensata.
Doc Brown
1
Mide antes de optimizar para saber si las compensaciones valen la pena. Const y el esfuerzo total es escribir siete caracteres, y tiene otras ventajas. Cuando no tiene la intención de modificar la variable que se pasa, es una ventaja incluso si no hay una mejora de la velocidad.
gnasher729
3
No soy un experto en C, entonces una pregunta: const& foodice 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.
user949300
1
@DocBrown eventualmente puede cuestionar la motivación del desarrollador que puso el const &? Si lo hiciera por rendimiento solo sin considerar el resto, podría considerarse una optimización prematura. Ahora, si lo pone porque sabe que será un parámetro constante, solo está auto documentando su código y dando la oportunidad al compilador de optimizar, lo cual es mejor.
Walfrat
1
@ user949300: Pocas funciones permiten que sus argumentos se modifiquen simultáneamente o mediante devoluciones de llamada, y lo dicen explícitamente.
Deduplicador
16

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, un class/ structque contiene un std::vectormiembro 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 Sortfunció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).

/*caller specifically passes in input argument destructively*/
std::vector<T> my_sort(std::vector<T>&& input)
{
    std::vector<T> result(std::move(input)); /* destructive move */
    std::sort(result.begin(), result.end()); /* in-place sorting */
    return result; /* return-value optimization (RVO) */
}

/*caller specifically passes in read-only argument*/ 
std::vector<T> my_sort(const std::vector<T>& input)
{
    /* reuse destructive implementation by letting it work on a clone. */
    /* Several things involved; e.g. expiring temporaries as r-value */
    /* return-value optimization, etc. */
    return my_sort(std::vector<T>(input));  
}

/*caller can select which to call, by selecting r-value*/
std::vector<T> v1 = {...};
std::vector<T> v2 = my_sort(v1); /*non-destructive*/
std::vector<T> v3 = my_sort(std::move(v1)); /*v1 is gutted*/    

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 memcpyfunció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) ...

  • Sí, afecta el rendimiento del software escrito en este idioma.
  • Si los compiladores pueden entender el propósito del código, podría ser lo suficientemente inteligente como para automatizar eso
  • Desafortunadamente, en lenguajes que favorecen la mutabilidad (en oposición a la pureza funcional), el compilador clasificaría la mayoría de las cosas como mutadas, por lo tanto, la deducción automática de la constidad rechazaría la mayoría de las cosas como no constantes.
  • La sobrecarga mental depende de las personas; las personas que consideran que esto es una gran sobrecarga mental habrían rechazado C ++ como un lenguaje de programación viable.
rwong
fuente
Esta es una de las situaciones en las que desearía poder aceptar dos respuestas en lugar de tener que elegir solo una ... suspiro
CharonX
8

En el artículo de DonaldKnuth "StructuredProgrammingWithGoToStatements", escribió: "Los programadores pierden enormes cantidades de tiempo pensando o preocupándose por la velocidad de las partes no críticas de sus programas, y estos intentos de eficiencia realmente tienen un fuerte impacto negativo cuando se consideran la depuración y el mantenimiento . Deberíamos olvidarnos de las pequeñas eficiencias, digamos alrededor del 97% del tiempo: la optimización prematura es la raíz de todo mal. Sin embargo, no debemos dejar pasar nuestras oportunidades en ese crítico 3% ". - Optimización prematura

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.

Lawrence
fuente
3
"si debes elegir solo uno, elige la claridad". El segundo debería preferirse , ya que podría verse obligado a elegir el otro.
Deduplicador
@Dupuplicator Gracias. Sin embargo, en el contexto del OP, el programador tiene la libertad de elegir.
Lawrence
Sin embargo, su respuesta es un poco más general que eso ...
Deduplicador
@Dupuplicator Ah, pero el contexto de mi respuesta es (también) que el programador elige. Si la elección fue forzada por el programador, no sería "usted" quien elegiría :). Considere el cambio que sugirió y no me opondría a que edite mi respuesta en consecuencia, pero prefiero la redacción existente por su claridad.
Lawrence
7

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:

void foo(X x);          // I intend to own the x you gave me, whether by copy, move or direct initialisation on the call stack.     

void foo(X&& x);        // I intend to steal x from you. Do not use it other than to re-assign to it after calling me.

void foo(X const& x);   // I guarantee not to change your x

void foo(X& x);         // I may modify your x and I will leave it in a defined state
Richard Hodges
fuente
3

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.

Jerry Coffin
fuente