Const sobrecarga llamada inesperadamente en gcc. ¿Error del compilador o corrección de compatibilidad?

8

Tenemos una aplicación mucho más grande que se basa en la sobrecarga de plantillas de matrices char y const char. En gcc 7.5, clang y visual studio, el siguiente código imprime "NO CONST" para todos los casos. Sin embargo, para gcc 8.1 y posterior, la salida se muestra a continuación:

#include <iostream>

class MyClass
{
public:
    template <size_t N>
    MyClass(const char (&value)[N])
    {
        std::cout << "CONST " << value << '\n';
    }

    template <size_t N>
    MyClass(char (&value)[N])
    {
        std::cout << "NON-CONST " << value << '\n';
    }
};

MyClass test_1()
{
    char buf[30] = "test_1";
    return buf;
}

MyClass test_2()
{
    char buf[30] = "test_2";
    return {buf};
}

void test_3()
{
    char buf[30] = "test_3";
    MyClass x{buf};
}

void test_4()
{
    char buf[30] = "test_4";
    MyClass x(buf);
}

void test_5()
{
    char buf[30] = "test_5";
    MyClass x = buf;
}

int main()
{
    test_1();
    test_2();
    test_3();
    test_4();
    test_5();
}

La salida de gcc 8 y 9 (de godbolt) es:

CONST test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5

Esto me parece un error del compilador, pero supongo que podría ser algún otro problema relacionado con un cambio de idioma. ¿Alguien sabe definitivamente?

Rob L
fuente
¿Has intentado compilar con diferentes versiones estándar de C ++?
n314159
1
g ++ y clang ++ difieren: godbolt.org/z/g3cCBL
Ted Lyngmo
@ n314159 Buena pregunta, lo acabo de hacer. -std = c ++ 11 -std = c ++ 14 -std = c ++ 17 y -std = c ++ 2a todos producen el mismo resultado "malo". No se compilará con -std = c ++ 03.
Rob L
1
@TedLyngmo sí, noté que el sonido metálico funciona como era de esperar, al igual que visual studio.
Rob L

Respuestas:

6

Cuando devuelve una expresión de identificación simple de una función (que designó un objeto local de función), el compilador tiene el mandato de hacer la resolución de sobrecarga dos veces. Primero lo trata como si fuera un valor, y no un valor. Solo si falla la primera resolución de sobrecarga, se volverá a realizar con el objeto como un valor l.

[class.copy.elision]

3 En los siguientes contextos de inicialización de copia, se puede usar una operación de movimiento en lugar de una operación de copia:

  • Si la expresión en una declaración de retorno es una expresión de identificación (posiblemente entre paréntesis) que nombra un objeto con una duración de almacenamiento automática declarada en el cuerpo o en la cláusula-declaración-parámetro de la función envolvente más interna o expresión-lambda, o

  • ...

La resolución de sobrecarga para seleccionar el constructor para la copia se realiza primero como si el objeto fuera designado por un valor r. Si la primera resolución de sobrecarga falla o no se realizó, o si el tipo del primer parámetro del constructor seleccionado no es una referencia de valor al tipo del objeto (posiblemente calificado por cv), la resolución de sobrecarga se realiza nuevamente, considerando el objeto como un lvalue. [Nota: Esta resolución de sobrecarga de dos etapas debe realizarse independientemente de si se producirá una elisión de copia. Determina el constructor que se llamará si no se realiza la elisión, y el constructor seleccionado debe ser accesible incluso si se elude la llamada. - nota final]

Si tuviéramos que agregar una sobrecarga de valor,

template <size_t N>
MyClass (char (&&value)[N])
{
    std::cout << "RVALUE " << value << '\n';
}

la salida se convertirá

RVALUE test_1
NON-CONST test_2
NON-CONST test_3
NON-CONST test_4
NON-CONST test_5

Y esto sería correcto. Lo que no es correcto es el comportamiento de GCC tal como lo ves. Considera que la primera resolución de sobrecarga es un éxito. Esto se debe a que una referencia de valor constante puede unirse a un valor r. Sin embargo, ignora el texto "o si el tipo del primer parámetro del constructor seleccionado no es una referencia de valor al tipo del objeto" . De acuerdo con eso, debe descartar el resultado de la primera resolución de sobrecarga y volver a hacerlo.

Bueno, esa es la situación hasta C ++ 17 de todos modos. El borrador estándar actual dice algo diferente.

Si la primera resolución de sobrecarga falla o no se realizó, la resolución de sobrecarga se realiza nuevamente, considerando la expresión u operando como un valor l.

Se eliminó el texto de hasta C ++ 17. Entonces es un error de viaje en el tiempo. GCC implementa el comportamiento de C ++ 20, pero lo hace incluso cuando el estándar es C ++ 17.

StoryTeller - Unslander Monica
fuente
¡Gracias! Parece que también me has dado una solución alternativa que funciona en este caso específico, agregando una sobrecarga de valor. Solo puedo hacer que sea idéntico al carácter y la versión.
Rob L
1
@RobL: encantado de ayudar. Aunque tenga en cuenta que la situación es un poco más matizada de lo que originalmente pensé. El texto realmente cambió. Me alegro de haberlo comprobado.
StoryTeller - Unslander Monica
Supongo que eso significa que clang++la implementación de C ++ 20 tampoco es correcta ya que usa la versión NO CONST en todos los casos en el código original.
Ted Lyngmo
2
@TedLyngmo: con los problemas de viajar en el tiempo, realmente es cuestión de cuándo. Me imagino que los desarrolladores de Clang simplemente no lograron implementar este cambio. No lo llamaré un error per se. GCC haciendo lo nuevo en C ++ 17 es probablemente un error. Depende de cómo se introdujo este cambio en el estándar. No creo que haya un informe de defectos que requiera que esto cambie retroactivamente, así que supongo que es un error de GCC. Soportar múltiples estándares es un trabajo matizado.
StoryTeller - Unslander Monica
1
@RobL: es un objeto local de función. Después de la declaración de devolución se ha ido. Este es un punto de optimización deliberado. En lugar de copiar, el objeto puede ser canibalizado. La práctica estándar es escribir tipos que se comporten correctamente para cualquier categoría de valor que se les entregue.
StoryTeller - Unslander Monica
0

Hay un debate sobre si esto es o no "comportamiento intuitivo" en los comentarios, por lo que pensé en apostar por el razonamiento detrás de este comportamiento.

Hubo una charla bastante agradable en CPPCON que me deja un poco más claro { talk , slides }. Básicamente, ¿qué implica una función que toma una referencia no constante? Que el objeto de entrada debe ser de lectura / escritura . Aún más fuerte, implica que tengo la intención de modificar este objeto, esta función tiene efectos secundarios . Una referencia constante implica solo lectura , y una referencia de valor significa que puedo tomar los recursos . Si test_1()terminara llamando al NON-CONSTconstructor, significaría que tengo la intención de modificar este objeto, aunque después de que haya terminado, ya no exista,lo cual (creo) sería un error (estoy pensando en un caso en el que la vinculación de una referencia durante la inicialización depende de si el argumento pasado es constante o no).

Lo que me preocupa un poco más es la sutileza introducida por test_2(). Aquí, se está llevando a cabo la inicialización de la lista de copias en lugar de las reglas relativas a [class.copy.elision] citadas anteriormente. Ahora realmente está diciendo que devuelva un objeto de tipo MyClass como si lo hubiera inicializado buf, por lo que NON-CONSTse invoca el comportamiento. Siempre he pensado en las listas de inicio como formas de ser más concisas, pero aquí las llaves hacen una diferencia semántica significativa. Esto importaría más si los constructores MyClasstomaran una gran cantidad de argumentos. Luego, digamos que desea crear un buf, modificarlo, luego devolverlo con la gran cantidad de argumentos, invocando el CONSTcomportamiento. Por ejemplo, digamos que tienes los constructores:

template <size_t N>
MyClass(const char (&value)[N], int)
{
    std::cout << "CONST int " << value << '\n';
}

template <size_t N>
MyClass(char (&value)[N], int)
{
    std::cout << "NON-CONST int " << value << '\n';
}

Y prueba:

MyClass test_0() {
    char buf[30] = "test_0";
    return {buf,0};
}

Godbolt nos dice que obtenemos un NON-CONSTcomportamiento, aunque CONSTprobablemente sea lo que queremos (después de haber bebido la buena ayuda en la semántica de argumentos de función). Pero ahora la inicialización de la lista de copias no hace lo que nos gustaría. El siguiente tipo de prueba mejora mi punto:

MyClass test_0() {
    char buf[30] = "test_0";
    buf[0] = 'T';
    const char (&bufR)[30]{buf};
    return {bufR,0};
}
// OUTPUT: CONST int Test_0

Ahora para obtener la semántica adecuada con la inicialización de la lista de copias, el búfer debe ser "rebote" al final. Supongo que si el objetivo fuera que este objeto fuera inicializar algún otro MyClassobjeto, solo usar el NON-CONSTcomportamiento en la lista de copias de retorno estaría bien si el constructor move / copy invocara el comportamiento apropiado, pero eso está comenzando a sonar bastante delicado.

Nathan Chappell
fuente