Estoy viendo la charla de Chandler Carruth en CppCon 2019:
No hay abstracciones de costo cero
en él, da el ejemplo de cómo se sorprendió por la cantidad de gastos generales en los que incurres al usar un std::unique_ptr<int>
over an int*
; ese segmento comienza aproximadamente en el punto de tiempo 17:25.
Puede echar un vistazo a los resultados de la compilación de su par de fragmentos de ejemplo (godbolt.org), para observar que, de hecho, parece que el compilador no está dispuesto a pasar el valor unique_ptr, que en realidad es el resultado final. solo una dirección, dentro de un registro, solo en memoria directa.
Uno de los puntos que el Sr. Carruth hace alrededor de las 27:00 es que el ABI de C ++ requiere que los parámetros por valor (algunos, pero no todos; tal vez, ¿tipos no primitivos? en lugar de dentro de un registro.
Mis preguntas:
- ¿Es esto realmente un requisito de ABI en algunas plataformas? (¿Cuál?) ¿O tal vez es solo un poco de pesimismo en ciertos escenarios?
- ¿Por qué es así el ABI? Es decir, si los campos de una estructura / clase se ajustan a los registros, o incluso a un único registro, ¿por qué no deberíamos poder pasarlo dentro de ese registro?
- ¿El comité de estándares C ++ ha discutido este punto en los últimos años, o alguna vez?
PD: para no dejar esta pregunta sin código:
Puntero liso:
void bar(int* ptr) noexcept;
void baz(int* ptr) noexcept;
void foo(int* ptr) noexcept {
if (*ptr > 42) {
bar(ptr);
*ptr = 42;
}
baz(ptr);
}
Puntero único:
using std::unique_ptr;
void bar(int* ptr) noexcept;
void baz(unique_ptr<int> ptr) noexcept;
void foo(unique_ptr<int> ptr) noexcept {
if (*ptr > 42) {
bar(ptr.get());
*ptr = 42;
}
baz(std::move(ptr));
}
fuente
this
puntero que apunta a una ubicación válida.unique_ptr
tiene esos. Derramar el registro para ese propósito negaría un poco toda la optimización de "pasar en un registro".Respuestas:
Un ejemplo es System V Aplicación Binary Interface AMD64 Arquitectura Suplemento procesador . Este ABI es para CPU de 64 bits compatibles con x86 (Linux x86_64 architecure). Se sigue en Solaris, Linux, FreeBSD, macOS, Windows Subsystem para Linux:
Tenga en cuenta que solo se pueden usar 2 registros de propósito general para pasar 1 objeto con un constructor de copia trivial y un destructor trivial, es decir, solo
sizeof
se pueden pasar valores de objetos con no más de 16 en los registros. Consulte Convenciones de llamadas de Agner Fog para un tratamiento detallado de las convenciones de llamadas, en particular §7.1 Pasar y devolver objetos. Existen convenciones de llamadas separadas para pasar tipos SIMD en los registros.Existen diferentes ABI para otras arquitecturas de CPU.
Es un detalle de implementación, pero cuando se maneja una excepción, durante el desbobinado de la pila, los objetos con la duración del almacenamiento automático que se destruye deben ser direccionables en relación con el marco de la pila de funciones porque los registros se han bloqueado en ese momento. El código de desenrollado de pila necesita las direcciones de los objetos para invocar sus destructores, pero los objetos en los registros no tienen una dirección.
Pendientemente, los destructores operan en objetos :
y un objeto no puede existir en C ++ si no se le asigna un almacenamiento direccionable porque la identidad del objeto es su dirección .
Cuando se necesita una dirección de un objeto con un constructor de copia trivial guardado en registros, el compilador puede almacenar el objeto en la memoria y obtener la dirección. Si el constructor de la copia no es trivial, por otro lado, el compilador no puede simplemente almacenarlo en la memoria, sino que necesita llamar al constructor de la copia que toma una referencia y, por lo tanto, requiere la dirección del objeto en los registros. La convención de llamada probablemente no puede depender de si el constructor de la copia fue incorporado en la llamada o no.
Otra forma de pensar en esto es que, para los tipos que se pueden copiar trivialmente, el compilador transfiere el valor de un objeto en registros, desde el cual un objeto puede recuperarse mediante almacenes de memoria simple si es necesario. P.ej:
en x86_64 con System V ABI compila en:
En su charla estimulante, Chandler Carruth menciona que puede ser necesario un cambio abrupto de ABI (entre otras cosas) para implementar el movimiento destructivo que podría mejorar las cosas. En mi opinión, el cambio de ABI podría no interrumpirse si las funciones que utilizan el nuevo ABI optan explícitamente por tener un nuevo enlace diferente, por ejemplo, declararlas en
extern "C++20" {}
bloque (posiblemente, en un nuevo espacio de nombres en línea para migrar las API existentes). Para que solo el código compilado contra las nuevas declaraciones de función con el nuevo enlace pueda usar el nuevo ABI.Tenga en cuenta que ABI no se aplica cuando la función llamada ha sido incorporada. Además de la generación de código de tiempo de enlace, el compilador puede incorporar funciones definidas en otras unidades de traducción o utilizar convenciones de llamada personalizadas.
fuente
Con ABI comunes, el destructor no trivial -> no puede pasar registros
(Una ilustración de un punto en la respuesta de @ MaximEgorushkin usando el ejemplo de @ harold en un comentario; corregido según el comentario de @ Yakk).
Si compilas:
usted obtiene:
es decir, el
Foo
objeto se pasa atest
un registro (edi
) y también se devuelve en un registro (eax
).Cuando el destructor no es trivial (como el
std::unique_ptr
ejemplo de los OP), los ABI comunes requieren la colocación en la pila. Esto es cierto incluso si el destructor no utiliza la dirección del objeto en absoluto.Por lo tanto, incluso en el caso extremo de un destructor de no hacer nada, si compila:
usted obtiene:
con carga y almacenamiento inútiles.
fuente
std::unique_ptr
en un registro no conforme.register
palabra clave tenía la intención de hacer trivial que la máquina física almacenara algo en un registro al bloquear cosas que prácticamente dificultan "no tener dirección" en la máquina física.Si algo es visible en el límite de la unidad de cumplimiento, entonces si se define implícita o explícitamente se convierte en parte de la ABI.
El problema fundamental es que los registros se guardan y restauran todo el tiempo a medida que avanza hacia abajo y hacia arriba en la pila de llamadas. Por lo tanto, no es práctico tener una referencia o puntero a ellos.
La alineación y las optimizaciones que resultan de ella es agradable cuando sucede, pero un diseñador de ABI no puede confiar en que suceda. Tienen que diseñar el ABI asumiendo el peor de los casos. No creo que los programadores estén muy contentos con un compilador donde el ABI cambió dependiendo del nivel de optimización.
Se puede pasar un tipo trivialmente copiable en los registros porque la operación de copia lógica se puede dividir en dos partes. Los parámetros se copian en los registros utilizados para pasar los parámetros por la persona que llama y luego se copia a la variable local por la persona que llama. Si la variable local tiene una ubicación de memoria o no es, por lo tanto, solo la preocupación de la persona que llama.
Por otro lado, un tipo en el que se debe usar un constructor de copia o movimiento no puede dividir su operación de copia de esta manera, por lo que debe pasarse en la memoria.
No tengo idea si los organismos de normalización han considerado esto.
La solución obvia para mí sería agregar movimientos destructivos adecuados (en lugar de la casa a mitad de camino actual de un "estado válido pero no especificado") al lenguaje, luego introducir una forma de marcar un tipo que permita "movimientos destructivos triviales "incluso si no permite copias triviales.
pero tal solución DEBERÍA romper el ABI del código existente para implementar los tipos existentes, lo que puede traer una buena resistencia (aunque el ABI se rompe como resultado de las nuevas versiones estándar de C ++ no tienen precedentes, por ejemplo, los cambios std :: string en C ++ 11 resultó en una ruptura de ABI.
fuente
unique_ptr
yshared_ptr
semántico: leshared_ptr<T>
permite proporcionar al ctor 1) un ptr x al objeto derivado U que se eliminará con el tipo estático U con la expresióndelete x;
(por lo que no necesita un dtor virtual aquí) 2) o Incluso una función de limpieza personalizada. Eso significa que el estado de tiempo de ejecución se usa dentro delshared_ptr
bloque de control para codificar esa información. OTOHunique_ptr
no tiene dicha funcionalidad y no codifica el comportamiento de eliminación en estado; la única forma de personalizar la limpieza es crear otra instancia de plantilla (otro tipo de clase).Primero necesitamos volver a lo que significa pasar por valor y por referencia.
Para lenguajes como Java y SML, el paso por valor es sencillo (y no hay paso por referencia), al igual que copiar un valor variable, ya que todas las variables son solo escalares y tienen una copia semántica incorporada: son lo que cuentan como aritmética escriba C ++ o "referencias" (punteros con diferentes nombres y sintaxis).
En C tenemos tipos escalares y definidos por el usuario:
En C ++, los tipos definidos por el usuario pueden tener una semántica de copia definida por el usuario, que permite una programación verdaderamente "orientada a objetos" con objetos con propiedad de sus recursos y operaciones de "copia profunda". En tal caso, una operación de copia es realmente una llamada a una función que casi puede realizar operaciones arbitrarias.
Para estructuras C compiladas como C ++, "copiar" todavía se define como llamar a la operación de copia definida por el usuario (ya sea constructor u operador de asignación), que el compilador genera implícitamente. Significa que la semántica de un programa de subconjunto común de C / C ++ es diferente en C y C ++: en C se copia un tipo de agregado completo, en C ++ se llama a una función de copia generada implícitamente para copiar cada miembro; El resultado final es que en cualquier caso se copia cada miembro.
(Creo que hay una excepción cuando se copia una estructura dentro de una unión).
Entonces, para un tipo de clase, la única forma (fuera de las copias de la unión) para hacer una nueva instancia es a través de un constructor (incluso para aquellos con constructores triviales generados por el compilador).
No puede tomar la dirección de un valor r mediante un operador unario,
&
pero eso no significa que no haya un objeto rvalue; y un objeto, por definición, tiene una dirección ; y esa dirección incluso está representada por una construcción de sintaxis: un objeto de tipo de clase solo puede ser creado por un constructor, y tiene unthis
puntero; pero para los tipos triviales, no hay un constructor escrito por el usuario, por lo que no hay lugar para colocarthis
hasta después de que se construye y se nombra la copia.Para el tipo escalar, el valor de un objeto es el valor r del objeto, el valor matemático puro almacenado en el objeto.
Para un tipo de clase, la única noción de un valor del objeto es otra copia del objeto, que solo puede ser realizada por un constructor de copia, una función real (aunque para tipos triviales esa función es especialmente trivial, a veces puede ser creado sin llamar al constructor). Eso significa que el valor del objeto es el resultado del cambio del estado global del programa por una ejecución . No accede matemáticamente.
Por lo tanto, pasar por valor realmente no es una cosa: es pasar por llamada de constructor de copia , que es menos bonita. Se espera que el constructor de copia realice una operación de "copia" sensata de acuerdo con la semántica adecuada del tipo de objeto, respetando sus invariantes internos (que son propiedades abstractas del usuario, no propiedades intrínsecas de C ++).
Pasar por valor de un objeto de clase significa:
Tenga en cuenta que el problema no tiene nada que ver con si la copia en sí es un objeto con una dirección: todos los parámetros de función son objetos y tienen una dirección (en el nivel semántico del lenguaje).
El problema es si:
En el caso de un tipo de clase trivial, aún puede definir el miembro de la copia miembro del original, por lo que puede definir el valor puro del original debido a la trivialidad de las operaciones de copia (constructor de copia y asignación). No es así con funciones de usuario especiales arbitrarias: un valor del original tiene que ser una copia construida.
Los objetos de clase deben ser construidos por la persona que llama; un constructor formalmente tiene un
this
puntero, pero el formalismo no es relevante aquí: todos los objetos tienen formalmente una dirección, pero solo aquellos que realmente usan su dirección de manera no puramente local (a diferencia de lo*&i = 1;
que es el uso puramente local de la dirección) deben tener un bien definido habla a.Un objeto debe pasar absolutamente por dirección si parece tener una dirección en estas dos funciones compiladas por separado:
Aquí, incluso si se
something(address)
trata de una función pura o macro o lo que sea (comoprintf("%p",arg)
) que no puede almacenar la dirección o comunicarse con otra entidad, tenemos el requisito de pasar por dirección porque la dirección debe estar bien definida para un objeto únicoint
que tiene un único identidad.No sabemos si una función externa será "pura" en términos de direcciones que se le pasen.
Aquí, el potencial para un uso real de la dirección en un constructor o destructor no trivial en el lado de la persona que llama es probablemente la razón para tomar la ruta segura y simplista y darle al objeto una identidad en la persona que llama y pasar su dirección, ya que hace asegúrese de que cualquier uso no trivial de su dirección en el constructor, después de la construcción y en el destructor sea consistente :
this
debe parecer ser el mismo sobre la existencia del objeto.Un constructor o destructor no trivial como cualquier otra función puede usar el
this
puntero de una manera que requiera consistencia sobre su valor a pesar de que algún objeto con cosas no triviales no:Tenga en cuenta que en ese caso, a pesar del uso explícito de un puntero (sintaxis explícita
this->
), la identidad del objeto es irrelevante: el compilador bien podría usar copiar bit a bit el objeto para moverlo y hacer "copiar elisión". Esto se basa en el nivel de "pureza" del uso dethis
funciones miembro especiales (la dirección no se escapa).Pero la pureza no es un atributo disponible en el nivel de declaración estándar (existen extensiones del compilador que agregan una descripción de pureza en la declaración de función no en línea), por lo que no puede definir un ABI basado en la pureza del código que puede no estar disponible (el código puede o puede no estar en línea y disponible para análisis).
La pureza se mide como "ciertamente pura" o "impura o desconocida". El terreno común, o límite superior de la semántica (en realidad el máximo), o LCM (mínimo común múltiplo) es "desconocido". Entonces el ABI se decide por lo desconocido.
Resumen:
Posible trabajo futuro:
¿Es la anotación de pureza lo suficientemente útil como para ser generalizada y estandarizada?
fuente
void foo(unique_ptr<int> ptr)
toma el objeto de clase por valor . Ese objeto tiene un miembro puntero, pero estamos hablando de que el objeto de clase se pasa por referencia. (Debido a que no es trivialmente copiable, su constructor / destructor necesita un coherentethis
). Ese es el argumento real y no está conectado al primer ejemplo de pasar por referencia explícitamente ; en ese caso, el puntero se pasa en un registro.int
: escribí un ejemplo de "archivo inteligente" que ilustra que "propiedad" no tiene nada que ver con "llevar un ptr".unique_ptr<T*>
, este es el mismo tamaño y diseño queT*
cabe en un registro. Los objetos de clase que se pueden copiar trivialmente se pueden pasar por valor en registros en x86-64 System V, como la mayoría de las convenciones de llamada. Esto hace una copia delunique_ptr
objeto, a diferencia de suint
ejemplo donde la persona que llama&i
es la dirección de la persona que llamai
porque pasó por referencia en el nivel de C ++ , no solo como un detalle de implementación de asm.unique_ptr
objeto; está utilizando,std::move
por lo que es seguro copiarlo porque eso no dará como resultado 2 copias de la mismaunique_ptr
. Pero para un tipo que se puede copiar trivialmente, sí, copia todo el objeto agregado. Si se trata de un solo miembro, las convenciones de llamadas buenas lo tratan igual que un escalar de ese tipo.struct{}
es una estructura C ++. Quizás deberías decir "estructuras simples" o "a diferencia de C". Porque sí, hay una diferencia. Si lo utilizaatomic_int
como miembro de estructura, C lo copiará de forma no atómica, error de C ++ en el constructor de copia eliminado. Olvidé lo que C ++ hace en estructuras convolatile
miembros. C le permitirá hacer lastruct tmp = volatile_struct;
copia completa (útil para un SeqLock); C ++ no lo hará.