He estado aprendiendo algo de C ++, y a menudo tengo que devolver objetos grandes de funciones que se crean dentro de la función. Sé que existe el paso por referencia, devolver un puntero y devolver soluciones de tipo de referencia, pero también he leído que los compiladores de C ++ (y el estándar C ++) permiten la optimización del valor de retorno, lo que evita copiar estos objetos grandes a través de la memoria, por lo tanto ahorrando el tiempo y la memoria de todo eso.
Ahora, siento que la sintaxis es mucho más clara cuando el valor devuelve explícitamente el objeto, y el compilador generalmente empleará el RVO y hará que el proceso sea más eficiente. ¿Es una mala práctica confiar en esta optimización? Hace que el código sea más claro y más legible para el usuario, lo cual es extremadamente importante, pero ¿debo tener cuidado de asumir que el compilador aprovechará la oportunidad de RVO?
¿Es esto una microoptimización o algo que debo tener en cuenta al diseñar mi código?
fuente
Respuestas:
Emplea el principio de menos asombro .
¿Eres tú y solo tú quien usará este código, y estás seguro de que lo mismo que tú en 3 años no te sorprenderá con lo que haces?
Entonces adelante.
En todos los demás casos, use la forma estándar; de lo contrario, usted y sus colegas se encontrarán con errores difíciles de encontrar.
Por ejemplo, mi colega se quejaba de que mi código causaba errores. Resulta que había desactivado la evaluación booleana de corto circuito en la configuración de su compilador. Casi lo abofeteo.
fuente
Para este caso en particular, definitivamente solo regrese por valor.
RVO y NRVO son optimizaciones bien conocidas y robustas que realmente deberían ser realizadas por cualquier compilador decente, incluso en modo C ++ 03.
La semántica de movimiento asegura que los objetos se muevan fuera de las funciones si (N) RVO no tuvo lugar. Eso solo es útil si su objeto usa datos dinámicos internamente (como lo
std::vector
hace), pero ese debería ser el caso si es tan grande: desbordar la pila es un riesgo con grandes objetos automáticos.C ++ 17 impone RVO. Así que no te preocupes, no desaparecerá en ti y solo terminará de establecerse por completo una vez que los compiladores estén actualizados.
Y al final, forzar una asignación dinámica adicional para que devuelva un puntero, o forzar que su tipo de resultado sea construible por defecto solo para que pueda pasarlo como un parámetro de salida son soluciones feas y no idiomáticas a un problema que probablemente nunca tendrá tener.
Simplemente escriba código que tenga sentido y agradezca a los escritores del compilador por optimizar correctamente el código que tiene sentido.
fuente
Esta no es una micro-optimización poco conocida, cursi, de la que lees en algún blog pequeño y poco traficado, y luego te sientes inteligente y superior sobre el uso.
Después de C ++ 11, RVO es la forma estándar de escribir este código de código. Es común, esperado, enseñado, mencionado en charlas, mencionado en blogs, mencionado en el estándar, será reportado como un error del compilador si no se implementa. En C ++ 17, el lenguaje va un paso más allá y obliga a copiar la elisión en ciertos escenarios.
Debe confiar absolutamente en esta optimización.
Además de eso, el retorno por valor solo conduce a un código enormemente más fácil de leer y administrar que el código que se devuelve por referencia. La semántica de valor es una cosa poderosa, que en sí misma podría conducir a más oportunidades de optimización.
fuente
La exactitud del código que escribe nunca debe depender de una optimización. Debe generar el resultado correcto cuando se ejecuta en la "máquina virtual" de C ++ que utilizan en la especificación.
Sin embargo, de lo que habla es más una cuestión de eficiencia. Su código funciona mejor si está optimizado con un compilador de optimización RVO. Eso está bien, por todas las razones señaladas en las otras respuestas.
Sin embargo, si necesita esta optimización (por ejemplo, si el constructor de la copia realmente haría que su código fallara), ahora está a gusto del compilador.
Creo que el mejor ejemplo de esto en mi propia práctica es la optimización de llamadas de cola:
Es un ejemplo tonto, pero muestra una llamada de cola, donde una función se llama recursivamente justo al final de una función. La máquina virtual C ++ mostrará que este código funciona correctamente, aunque puedo causar un poco de confusión en cuanto a por qué me molesté en escribir una rutina de suma en primer lugar. Sin embargo, en implementaciones prácticas de C ++, tenemos una pila y tiene un espacio limitado. Si se realiza de forma pedante, esta función debería empujar al menos
b + 1
los cuadros de la pila a la pila a medida que se agrega. Si quiero calcularsillyAdd(5, 7)
, esto no es gran cosa. Si quiero calcularsillyAdd(0, 1000000000)
, podría tener problemas reales de causar un StackOverflow (y no del tipo bueno ).Sin embargo, podemos ver que una vez que llegamos a la última línea de retorno, realmente hemos terminado con todo en el marco de la pila actual. Realmente no necesitamos mantenerlo ahí. La optimización de llamadas de cola le permite "reutilizar" el marco de pila existente para la siguiente función. De esta manera, solo necesitamos 1 marco de pila, en lugar de
b+1
. (Todavía tenemos que hacer todas esas adiciones y restas tontas, pero no ocupan más espacio). En efecto, la optimización convierte el código en:En algunos idiomas, la especificación requiere explícitamente la optimización de las llamadas de cola. C ++ no es uno de esos. No puedo confiar en los compiladores de C ++ para reconocer esta oportunidad de optimización de llamadas de cola, a menos que vaya caso por caso. Con mi versión de Visual Studio, la versión de lanzamiento optimiza las llamadas de cola, pero la versión de depuración no (por diseño).
Por lo tanto, sería malo para mí depender de poder calcular
sillyAdd(0, 1000000000)
.fuente
#ifdef
bloques y tienen disponible una solución alternativa que cumple con los estándares.b = b + 1
?En la práctica, los programas de C ++ esperan algunas optimizaciones del compilador.
Mire notablemente los encabezados estándar de sus implementaciones de contenedores estándar . Con GCC , puede solicitar el formulario preprocesado (
g++ -C -E
) y la representación interna de GIMPLE (g++ -fdump-tree-gimple
o Gimple SSA con-fdump-tree-ssa
) de la mayoría de los archivos fuente (unidades de traducción técnica) utilizando contenedores. Te sorprenderá la cantidad de optimización que se realiza (cong++ -O2
). Por lo tanto, los implementadores de contenedores confían en las optimizaciones (y la mayoría de las veces, el implementador de una biblioteca estándar de C ++ sabe qué optimización sucedería y escribe la implementación del contenedor con esos en mente; a veces también escribía el pase de optimización en el compilador para lidiar con las características requeridas por la biblioteca estándar de C ++).En la práctica, son las optimizaciones del compilador las que hacen que C ++ y sus contenedores estándar sean lo suficientemente eficientes. Entonces puedes confiar en ellos.
Y del mismo modo para el caso de RVO mencionado en su pregunta.
El estándar C ++ fue codiseñado (especialmente experimentando optimizaciones lo suficientemente buenas al proponer nuevas características) para funcionar bien con las posibles optimizaciones.
Por ejemplo, considere el siguiente programa:
compilarlo con
g++ -O3 -fverbose-asm -S
. Descubrirá que la función generada no ejecuta ningunaCALL
instrucción de máquina. Por lo tanto, la mayoría de los pasos de C ++ (construcción de un cierre lambda, su aplicación repetida, obtener los iteradoresbegin
yend
, etc.) se han optimizado. El código de máquina contiene solo un bucle (que no aparece explícitamente en el código fuente). Sin tales optimizaciones, C ++ 11 no tendrá éxito.adenda
(diciembre añadido 31 st 2017)
Ver CppCon 2017: Matt Godbolt “¿Qué ha hecho mi compilador últimamente por mí? Desatornillar la tapa del compilador ” .
fuente
Siempre que use un compilador, el entendimiento es que producirá código de máquina o byte para usted. No garantiza nada acerca de cómo es ese código generado, excepto que implementará el código fuente de acuerdo con la especificación del lenguaje. Tenga en cuenta que esta garantía es la misma independientemente del nivel de optimización utilizado, por lo que, en general, no hay razón para considerar una salida como más "correcta" que la otra.
Además, en esos casos, como RVO, donde se especifica en el idioma, parecería inútil hacer todo lo posible para evitar usarlo, especialmente si hace que el código fuente sea más simple.
Se pone mucho esfuerzo en hacer que los compiladores produzcan resultados eficientes, y claramente la intención es que esas capacidades se utilicen.
Puede haber razones para usar código no optimizado (para la depuración, por ejemplo), pero el caso mencionado en esta pregunta no parece ser uno (y si su código falla solo cuando está optimizado, y no es consecuencia de alguna peculiaridad del dispositivo en el que lo está ejecutando, entonces hay un error en alguna parte, y es poco probable que esté en el compilador).
fuente
Creo que otros cubrieron bien el ángulo específico sobre C ++ y RVO. Aquí hay una respuesta más general:
Cuando se trata de la corrección, no debe confiar en las optimizaciones del compilador ni en el comportamiento específico del compilador en general. Afortunadamente, no parece estar haciendo esto.
Cuando se trata de rendimiento, debe confiar en el comportamiento específico del compilador en general, y en las optimizaciones del compilador en particular. Un compilador compatible con el estándar es libre de compilar su código de la forma que desee, siempre que el código compilado se comporte de acuerdo con las especificaciones del lenguaje. Y no conozco ninguna especificación para un lenguaje convencional que especifique qué tan rápido debe ser cada operación.
fuente
Las optimizaciones del compilador solo deberían afectar el rendimiento, no los resultados. Confiar en las optimizaciones del compilador para cumplir con los requisitos no funcionales no solo es razonable, sino que con frecuencia es la razón por la cual un compilador se elige sobre otro.
Las banderas que determinan cómo se realizan operaciones particulares (condiciones de índice o desbordamiento, por ejemplo), a menudo se agrupan con optimizaciones del compilador, pero no deberían. Eficazmente afectan los resultados de los cálculos.
Si la optimización del compilador produce resultados diferentes, eso es un error, un error en el compilador. Confiar en un error en el compilador es a largo plazo un error: ¿qué sucede cuando se soluciona?
El uso de indicadores de compilación que cambian la forma en que funcionan los cálculos debe documentarse bien, pero usarse según sea necesario.
fuente
x*y>z
arroja arbitrariamente 0 o 1 en caso de desbordamiento, siempre que no tenga otros efectos secundarios , lo que requiere que un programador evite los desbordamientos a toda costa u obligue al compilador a evaluar la expresión de una manera particular innecesario perjudicar las optimizaciones frente a decir eso ...x*y
promoviera sus operandos a un tipo arbitrario más largo (permitiendo así formas de elevación y reducción de fuerza que cambiarían el comportamiento de algunos casos de desbordamiento). Sin embargo, muchos compiladores requieren que los programadores eviten el desbordamiento a toda costa o obliguen a los compiladores a truncar todos los valores intermedios en caso de desbordamiento.No.
Eso es lo que hago todo el tiempo. Si necesito acceder a un bloque arbitrario de 16 bits en la memoria, hago esto
... y confíe en que el compilador haga todo lo posible para optimizar ese fragmento de código. El código funciona en ARM, i386, AMD64 y prácticamente en todas las arquitecturas existentes. En teoría, un compilador no optimizador podría realmente llamar
memcpy
, lo que resulta en un rendimiento totalmente malo, pero eso no es un problema para mí, ya que uso optimizaciones de compilador.Considere la alternativa:
Este código alternativo no funciona en máquinas que requieren una alineación adecuada, si
get_pointer()
devuelve un puntero no alineado. Además, puede haber problemas de alias en la alternativa.La diferencia entre -O2 y -O0 cuando se usa el
memcpy
truco es excelente: 3.2 Gbps de rendimiento de suma de verificación IP versus 67 Gbps de rendimiento de suma de verificación IP. En un orden de diferencia de magnitud!A veces puede que necesite ayudar al compilador. Entonces, por ejemplo, en lugar de confiar en el compilador para desenrollar bucles, puede hacerlo usted mismo. Ya sea implementando el famoso dispositivo de Duff o de una manera más limpia.
El inconveniente de confiar en las optimizaciones del compilador es que si ejecuta gdb para depurar su código, puede descubrir que se ha optimizado mucho. Por lo tanto, es posible que deba volver a compilar con -O0, lo que significa que el rendimiento será totalmente malo al depurar. Creo que este es un inconveniente que vale la pena tomar, considerando los beneficios de optimizar los compiladores.
Hagas lo que hagas, asegúrate de que tu camino no sea un comportamiento indefinido. Ciertamente, acceder a algún bloque aleatorio de memoria como un entero de 16 bits es un comportamiento indefinido debido a problemas de alias y alineación.
fuente
Todos los intentos de código eficiente escrito en cualquier cosa que no sea ensamblado se basan en gran medida en las optimizaciones del compilador, comenzando con la asignación de registro más básica como eficiente para evitar derrames de pila superfluos en todo el lugar y al menos una selección de instrucciones razonablemente buena, si no excelente. De lo contrario, volveríamos a los años 80, donde tuvimos que poner
register
pistas por todas partes y usar el número mínimo de variables en una función para ayudar a los compiladores arcaicos de C o incluso antes, cuandogoto
era una optimización de ramificación útil.Si no pensáramos que podríamos confiar en la capacidad de nuestro optimizador para optimizar nuestro código, todos estaríamos codificando rutas de ejecución críticas para el rendimiento en el ensamblaje.
Realmente es una cuestión de cuán confiablemente creas que se puede hacer la optimización, que se resuelve mejor perfilando y analizando las capacidades de los compiladores que tienes y posiblemente incluso desensamblando si hay un punto de acceso que no puedes descubrir dónde parece el compilador no han podido hacer una optimización obvia.
RVO es algo que ha existido durante siglos y, al menos excluyendo casos muy complejos, es algo que los compiladores han estado aplicando de manera confiable durante años. Definitivamente no vale la pena solucionar un problema que no existe.
Errar al lado de confiar en el optimizador, no temerlo
Por el contrario, diría que errar del lado de confiar demasiado en las optimizaciones del compilador que muy poco, y esta sugerencia proviene de un tipo que trabaja en campos muy críticos para el rendimiento, donde la eficiencia, el mantenimiento y la calidad percibida entre los clientes es todo un desenfoque gigante. Prefiero que confíes con demasiada confianza en tu optimizador y encuentres algunos casos oscuros en los que confías demasiado que confías demasiado poco y simplemente te encargas de temores supersticiosos todo el tiempo por el resto de tu vida. Al menos, eso hará que busques un perfilador e investiges adecuadamente si las cosas no se ejecutan tan rápido como deberían y obtendrás un valioso conocimiento, no supersticiones, en el camino.
Estás haciendo bien en apoyarte en el optimizador. Seguid así. No se convierta en ese tipo que comienza a solicitar explícitamente que se incorporen todas las funciones llamadas en un bucle antes de perfilarse por miedo equivocado a las deficiencias del optimizador.
Perfilado
La elaboración de perfiles es realmente la respuesta indirecta pero definitiva a su pregunta. El problema con el que los principiantes ansiosos por escribir código eficiente a menudo luchan no es qué optimizar, sino qué no optimizar porque desarrollan todo tipo de corazonadas equivocadas sobre ineficiencias que, aunque son intuitivamente humanas, son computacionalmente erróneas. El desarrollo de la experiencia con un generador de perfiles comenzará realmente a darle una apreciación adecuada no solo de las capacidades de optimización de sus compiladores en las que puede confiar con confianza, sino también de las capacidades (así como las limitaciones) de su hardware. Podría decirse que es aún más valioso perfilar para aprender lo que no valió la pena optimizar que aprender lo que fue.
fuente
El software se puede escribir en C ++ en plataformas muy diferentes y para muchos propósitos diferentes.
Depende completamente del propósito del software. Si fuera fácil de mantener, expandir, parchear, refactorizar, etc. o son otras cosas más importantes, como el rendimiento, el costo o la compatibilidad con algún hardware específico o el tiempo que lleva desarrollarlo.
fuente
Creo que la respuesta aburrida a esto es: "depende".
¿Es una mala práctica escribir código que se base en una optimización del compilador que probablemente esté desactivada y donde la vulnerabilidad no esté documentada y donde el código en cuestión no se pruebe unitariamente, de modo que si se rompe lo sabría ? Probablemente.
¿Es una mala práctica escribir código que se base en una optimización del compilador que probablemente no esté desactivada , que esté documentada y se pruebe la unidad ? Tal vez no.
fuente
A menos que haya más cosas que no nos está diciendo, esta es una mala práctica, pero no por la razón que sugiere.
Posiblemente, a diferencia de otros lenguajes que haya utilizado antes, devolver el valor de un objeto en C ++ produce una copia del objeto. Si luego modifica el objeto, está modificando un objeto diferente . Es decir, si tengo
Obj a; a.x=1;
yObj b = a;
luego lo hagob.x += 2; b.f();
, entoncesa.x
igual es 1, no 3.Entonces, no, el uso de un objeto como valor en lugar de como referencia o puntero no proporciona la misma funcionalidad y podría terminar con errores en su software.
Quizás lo sepa y no afecte negativamente su caso de uso específico. Sin embargo, según la redacción de su pregunta, parece que es posible que no tenga conocimiento de la distinción; redacción como "crear un objeto en la función".
"crear un objeto en la función" suena como
new Obj;
donde "devolver el objeto por valor" suena comoObj a; return a;
Obj a;
yObj* a = new Obj;
son cosas muy, muy diferentes; el primero puede provocar daños en la memoria si no se usa y comprende correctamente, y el segundo puede provocar pérdidas de memoria si no se usa y comprende correctamente.fuente
return
instrucción que es un requisito para RVO. Además, luego pasa a hablar sobre palabras clavenew
y punteros, que no es de lo que se trata RVO. Creo que o no entiendes la pregunta, o RVO, o posiblemente ambos.Pieter B tiene toda la razón al recomendar el menor asombro.
Para responder a su pregunta específica, lo que esto (muy probablemente) significa en C ++ es que debe devolver
std::unique_ptr
a al objeto construido.La razón es que esto es más claro para un desarrollador de C ++ en cuanto a lo que está sucediendo.
Aunque lo más probable es que su enfoque funcione, está indicando efectivamente que el objeto es un tipo de valor pequeño cuando, de hecho, no lo es. Además de eso, está descartando cualquier posibilidad de abstracción de interfaz. Esto puede estar bien para sus propósitos actuales, pero a menudo es muy útil cuando se trata de matrices.
Aprecio que si vienes de otros idiomas, todos los sigilos pueden ser confusos inicialmente. Pero tenga cuidado de no asumir que, al no usarlos, aclara su código. En la práctica, lo contrario es probable que sea cierto.
fuente
std::make_unique
, nostd::unique_ptr
directamente. En segundo lugar, RVO no es una optimización esotérica específica del proveedor: está integrada en el estándar. Incluso cuando no lo era, tenía un amplio apoyo y comportamiento esperado. No tiene sentido devolver astd::unique_ptr
cuando no se necesita un puntero en primer lugar.