Sobrecarga Const inesperadamente chamada em gcc. Bug do compilador ou correção de compatibilidade?

8

Temos um aplicativo muito maior que depende da sobrecarga de modelo de matrizes char e const char. No gcc 7.5, clang e visual studio, o código abaixo imprime "NÃO CONST" para todos os casos. No entanto, para o gcc 8.1 e posterior, a saída é mostrada abaixo:

#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();
}

A saída gcc 8 e 9 (do godbolt) é:

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

Isso me parece um bug do compilador, mas acho que poderia ser outro problema relacionado a uma alteração de idioma. Alguém sabe definitivamente?

Rob L
fonte
Você já tentou compilar com diferentes versões padrão do C ++?
n314159 27/01
1
g ++ e clang ++ diferem: godbolt.org/z/g3cCBL
Ted Lyngmo
@ n314159 Boa pergunta, acabei de fazer. -std = c ++ 11 -std = c ++ 14 -std = c ++ 17 e -std = c ++ 2a produzem o mesmo resultado "ruim". Não será compilado com -std = c ++ 03.
Rob L
1
@TedLyngmo sim, observei que o clang funciona como eu esperaria, assim como o visual studio.
Rob L

Respostas:

6

Quando você retorna uma expressão de identificação simples de uma função (que designou um objeto local da função), o compilador é obrigado a executar a resolução de sobrecarga duas vezes. Primeiro, trata-o como se fosse um valor, e não um valor. Somente se a primeira resolução de sobrecarga falhar, ela será executada novamente com o objeto como um valor l.

[class.copy.elision]

3 Nos seguintes contextos de inicialização de cópia, uma operação de movimentação pode ser usada em vez de uma operação de cópia:

  • Se a expressão em uma instrução de retorno for uma expressão de identificação (possivelmente entre parênteses) que nomeie um objeto com duração de armazenamento automática declarada no corpo ou na cláusula de declaração de parâmetro da função de fechamento mais interna ou expressão lambda, ou

  • ...

a resolução de sobrecarga para selecionar o construtor da cópia é realizada primeiro como se o objeto fosse designado por um rvalue. Se a primeira resolução de sobrecarga falhar ou não tiver sido executada, ou se o tipo do primeiro parâmetro do construtor selecionado não for uma referência de rvalor ao tipo do objeto (possivelmente qualificado pelo CV), a resolução de sobrecarga será executada novamente, considerando o objeto como um lvalue. [Nota: Essa resolução de sobrecarga em dois estágios deve ser executada, independentemente da ocorrência de elisão da cópia. Determina o construtor a ser chamado se a elisão não for executada e o construtor selecionado deve estar acessível, mesmo que a chamada seja evitada. - nota final]

Se adicionarmos uma sobrecarga de rvalue,

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

a saída se tornará

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

e isso estaria correto. O que não está correto é o comportamento do GCC como você o vê. Considera a primeira resolução de sobrecarga um sucesso. Isso ocorre porque uma referência const lvalue pode se ligar a um rvalue. No entanto, ele ignora o texto "ou se o tipo do primeiro parâmetro do construtor selecionado não for uma referência rvalue ao tipo do objeto" . De acordo com isso, ele deve descartar o resultado da primeira resolução de sobrecarga e fazê-lo novamente.

Bem, essa é a situação até C ++ 17 de qualquer maneira. O rascunho padrão atual diz algo diferente.

Se a primeira resolução de sobrecarga falhar ou não for executada, a resolução de sobrecarga será executada novamente, considerando a expressão ou operando como um valor l.

O texto de até C ++ 17 foi removido. Portanto, é um bug de viagem no tempo. O GCC implementa o comportamento do C ++ 20, mas o faz mesmo quando o padrão é C ++ 17.

Contador de Histórias - Monica Sem Calúnia
fonte
Obrigado! Parece que você também me forneceu uma solução alternativa que funciona nesse caso específico, adicionando uma sobrecarga de rvalue. Eu posso apenas fazer isso idêntico ao char & versão.
Rob L
1
@RobL - Feliz em ajudar. Embora observe que a situação é um pouco mais sutil do que eu pensava originalmente. O texto realmente mudou. Estou feliz por ter verificado.
StoryTeller - Unslander Monica
Eu acho que isso significa que clang++a implementação do C ++ 20 também não está correta, uma vez que usa a versão NON-CONST em todos os casos no código original.
Ted Lyngmo
2
@TedLyngmo - Com problemas de viagem no tempo, é realmente uma questão de quando. Eu imagino que os desenvolvedores do Clang simplesmente não conseguiram implementar essa mudança. Não vou chamá-lo de bug em si. O GCC que faz o novo no C ++ 17 provavelmente é um bug. Depende de como essa alteração foi inserida no padrão. Não acredito que tenha havido um relatório de defeitos que exija a alteração retroativa, por isso presumo que seja um bug do GCC. O suporte a vários padrões é um trabalho diferenciado.
StoryTeller - Unslander Monica
1
@RobL - É um objeto local de função. Após a declaração de retorno, ela se foi. Este é um ponto de otimização deliberado. Em vez de copiar, o objeto pode ser canibalizado. A prática padrão é escrever tipos que se comportam corretamente para qualquer categoria de valor que eles receberem.
StoryTeller - Unslander Monica
0

Há um debate sobre se esse é ou não um "comportamento intuitivo" nos comentários, então pensei em dar uma facada no raciocínio por trás desse comportamento.

Há uma palestra bastante agradável que foi dada no CPPCON que torna isso um pouco mais claro para mim { conversa , slides }. Basicamente, o que implica uma função que usa uma referência não-const? Que o objeto de entrada deve ser de leitura / gravação . Ainda mais forte, implica que pretendo modificar esse objeto, essa função tem efeitos colaterais . Um const ref implica somente leitura , e rvalue ref significa que eu posso pegar os recursos . Se você test_1()acabar chamando o NON-CONSTconstrutor, isso significa que pretendo modificar esse objeto, mesmo depois de concluído, ele não existe mais,que (acho) seria um bug (estou pensando em um caso em que uma referência é vinculada durante a inicialização depende se o argumento passado é const ou não).

O que é um pouco mais preocupante para mim é a sutileza introduzida por test_2(). Aqui, a inicialização da lista de cópias está ocorrendo em vez das regras relacionadas a [class.copy.elision] citadas acima. Agora você está realmente dizendo para retornar um objeto do tipo MyClass como se eu o tivesse inicializado buf, para que o NON-CONSTcomportamento seja invocado. Eu sempre pensei nas listas init como maneiras de ser mais concisas, mas aqui os aparelhos fazem uma diferença semântica significativa. Isso importaria mais se os construtores tivessem MyClassum grande número de argumentos. Então, diga que você deseja criar buf, modifique-o e retorne-o com o grande número de argumentos, invocando o CONSTcomportamento. Por exemplo, digamos que você tenha os construtores:

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';
}

E teste:

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

Godbolt nos diz que obtemos NON-CONSTcomportamento, embora CONSTseja provavelmente o que queremos (depois de você ter bebido o auxílio legal na semântica de argumentos de função). Mas agora a inicialização da lista de cópias não faz o que gostaríamos. O teste a seguir melhora meu argumento:

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

Agora, para obter a semântica adequada com a inicialização da lista de cópias, o buffer precisa ser "recuperado" no final. Eu acho que se o objetivo fosse que esse objeto inicializasse outro MyClassobjeto, apenas o uso do NON-CONSTcomportamento na lista de cópias de retorno seria bom se o construtor de movimentação / cópia invocasse qualquer que seja o comportamento apropriado, mas isso está começando a parecer bastante delicado.

Nathan Chappell
fonte