Padrão C ++ 20: Uma visão geral dos novos recursos do C ++. Parte 3 "Conceitos"





No dia 25 de fevereiro, o autor do curso "Desenvolvedor C ++" em Yandex. Trabalho prático Georgy Osipov falou sobre a nova etapa da linguagem C ++ - o C ++ 20 Standard. A palestra fornece uma visão geral de todas as principais inovações do Padrão, informa como aplicá-las agora e como podem ser úteis.



Ao preparar o webinar, o objetivo era fornecer uma visão geral de todos os principais recursos do C ++ 20. Portanto, o webinar acabou sendo rico e durou quase 2,5 horas. Para sua conveniência, dividimos o texto em seis partes:



  1. Módulos e uma breve história do C ++ .
  2. Operação "nave espacial" .
  3. Conceitos.
  4. Gamas.
  5. Corrotinas.
  6. Outros recursos básicos e padrão da biblioteca. Conclusão.


Esta é a terceira parte, cobrindo os conceitos e limitações do C ++ moderno.



Conceitos







Motivação



A programação genérica é uma das principais vantagens do C ++. Não conheço todos os idiomas, mas nunca vi nada parecido a este nível.



Entretanto, a programação genérica em C ++ tem uma grande desvantagem: os erros que ocorrem são dolorosos. Considere um programa simples que classifica um vetor. Dê uma olhada no código e diga-me onde está o erro:



#include <vector>
#include <algorithm>
struct X {
    int a;
};
int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    //  
    std::sort(v.begin(), v.end());
}
      
      





Eu defini uma estrutura X



com um campo int



, preenchi um vetor com objetos dessa estrutura e estou tentando classificá-lo.



Espero que você leia o exemplo e encontre o bug. Anunciarei a resposta: o compilador acha que o erro está na ... biblioteca padrão. A saída de diagnóstico tem aproximadamente 60 linhas e indica um erro em algum lugar dentro do arquivo auxiliar xutility. É quase impossível ler e entender os diagnósticos, mas os programadores C ++ fazem isso - afinal, você ainda precisa usar modelos.







O compilador mostra que o erro está na biblioteca padrão, mas isso não significa que você precise escrever imediatamente para o Comitê de Padronização. Na verdade, o erro ainda está em nosso programa. Acontece que o compilador não é inteligente o suficiente para descobrir isso e ocorre um erro ao entrar na biblioteca padrão. Desvendar esse diagnóstico leva a um erro. Mas isso:



  • complicado,
  • nem sempre é possível em princípio.


Vamos formular o primeiro problema de programação genérica em C ++: erros ao usar modelos são completamente ilegíveis e são diagnosticados não onde foram feitos, mas no modelo.



Outro problema surge se houver necessidade de usar diferentes implementações de uma função dependendo das propriedades do tipo de argumento. Por exemplo, quero escrever uma função que verifique se dois números estão próximos o suficiente um do outro. Para números inteiros é suficiente verificar se os números são iguais, para números em ponto flutuante é suficiente verificar se a diferença é menor que algum ε.



O problema pode ser resolvido com o hack SFINAE escrevendo duas funções. Hack usa std::enable_if



... Este é um modelo especial na biblioteca padrão que contém um erro se a condição não for atendida. Ao instanciar um modelo, o compilador descarta as declarações com um erro:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
std::enable_if_t<std::is_floating_point_v<T>, bool>
AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
std::enable_if_t<!std::is_floating_point_v<T>, bool> 
AreClose(T a, T b) {
    return a == b;
}
      
      





No C ++ 17, esse programa pode ser simplificado usando if constexpr



, embora isso não funcione em todos os casos.



Ou outro exemplo: quero escrever uma função Print



que imprima qualquer coisa. Se um container foi passado para ele, ele irá imprimir todos os elementos, senão o container irá imprimir o que foi passado. Vou ter que defini-la para todos os recipientes: vector



, list



, set



e outros. Isso é inconveniente e não universal.



template<class T>
void Print(std::ostream& out, const std::vector<T>& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

//      map, set, list, 
// deque, array…

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





SFINAE não ajudará mais aqui. Em vez disso, vai ajudar se você tentar, mas você terá que tentar muito, e o código se revelará monstruoso.



O segundo problema com a programação genérica é que é difícil escrever diferentes implementações da mesma função de modelo para diferentes categorias de tipos.



Ambos os problemas podem ser facilmente resolvidos se você adicionar apenas um recurso ao idioma - impor restrições aos parâmetros do modelo . Por exemplo, exija que o parâmetro modelado seja um contêiner ou objeto que ofereça suporte a comparações. Este é o conceito.



O que outros têm



Vamos ver como estão as coisas em outras línguas. O único que conheço que tem algo semelhante é Haskell.



class Eq a where
	(==) :: a -> a -> Bool
	(/=) :: a -> a -> Bool
      
      





Este é um exemplo de uma classe de tipo que requer suporte para a emissão de operadores "igual" e "diferente" Bool



. Em C ++, o mesmo seria feito assim:



template<typename T>
concept Eq =
    requires(T a, T b) {
        { a == b } -> std::convertible_to<bool>;
        { a != b } -> std::convertible_to<bool>;
    };
      
      





Se você ainda não está familiarizado com os conceitos, será difícil entender o que está escrito. Vou explicar tudo agora.



Em Haskell, essas restrições são obrigatórias. Se você não disser que haverá uma operação ==



, você não poderá usá-la. Em C ++, as restrições não são rígidas. Mesmo que você não especifique uma operação no conceito, ela ainda pode ser usada - afinal, não havia nenhuma restrição antes, e os novos padrões se esforçam para não violar a compatibilidade com os anteriores.



Exemplo



Vamos complementar o código do programa no qual você estava procurando um erro recentemente:



#include <vector>
#include <algorithm>
#include <concepts>

template<class T>
concept IterToComparable = 
    requires(T a, T b) {
        {*a < *b} -> std::convertible_to<bool>;
    };
    
//    IterToComparable   class
template<IterToComparable InputIt>
void SortDefaultComparator(InputIt begin, InputIt end) {
    std::sort(begin, end);
}

struct X {
    int a;
};

int main() {
    std::vector<X> v = { {10}, {9}, {11} };
    SortDefaultComparator(v.begin(), v.end());
}
      
      





Aqui, criamos um conceito IterToComparable



. Mostra que o tipo T



é um iterador e aponta para valores que podem ser comparados. O resultado da comparação é algo conversível para bool



, por exemplo, ele mesmo bool



. Uma explicação detalhada será fornecida um pouco mais tarde, por enquanto você não precisa se aprofundar neste código.



A propósito, as restrições são fracas. Não diz que um tipo deve satisfazer todas as propriedades dos iteradores: por exemplo, ele não precisa ser incrementado. Este é um exemplo simples para demonstrar as possibilidades.



O conceito foi usado no lugar de uma palavra class



ou typename



na construção de c template



. Costumava ser template<class InputIt>



, mas agora a palavra class



substituído pelo nome do conceito. Portanto, o parâmetro InputIt



deve satisfazer a restrição.



Agora, quando tentamos compilar este programa, o erro aparecerá não na biblioteca padrão, mas como deveria estar - em main



. E o erro é compreensível, pois contém todas as informações necessárias:



  • O que aconteceu? Chamada de função com restrição não cumprida.
  • Qual restrição não foi satisfeita? IterToComparable<InputIt>



  • Por quê? A expressão é ((* a) < (* b))



    inválida.




A saída do compilador é legível e ocupa 16 linhas em vez de 60.



main.cpp: In function 'int main()':
main.cpp:24:45: error: **use of function** 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]' **with unsatisfied constraints**
   24 |     SortDefaultComparator(v.begin(), v.end());
      |                                             ^
main.cpp:12:6: note: declared here
   12 | void SortDefaultComparator(InputIt begin, InputIt end) {
      |      ^~~~~~~~~~~~~~~~~~~~~
main.cpp:12:6: note: constraints not satisfied
main.cpp: In instantiation of 'void SortDefaultComparator(InputIt, InputIt) [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X> >]':
main.cpp:24:45:   required from here
main.cpp:6:9:   **required for the satisfaction of 'IterToComparable<InputIt>'** [with InputIt = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:7:5:   in requirements with 'T a', 'T b' [with T = __gnu_cxx::__normal_iterator<X*, std::vector<X, std::allocator<X> > >]
main.cpp:8:13: note: the required **expression '((* a) < (* b))' is invalid**, because
    8 |         {*a < *b} -> std::convertible_to<bool>;
      |          ~~~^~~~
main.cpp:8:13: error: no match for 'operator<' (operand types are 'X' and 'X')
      
      





Vamos adicionar a operação de comparação ausente à estrutura, e o programa irá compilar sem erros - o conceito está satisfeito:



struct X {
    auto operator<=>(const X&) const = default;
    int a;
};
      
      





Da mesma forma, você pode melhorar o segundo exemplo, p enable_if



. Este modelo não é mais necessário. Em vez disso, usamos o conceito padrão is_floating_point_v<T>



. Obtemos duas funções: uma para números de ponto flutuante, a outra para outros objetos:



#include <type_traits>

template <class T>
T Abs(T x) {
    return x >= 0 ? x : -x;
}

//      
template<class T>
requires(std::is_floating_point_v<T>)
bool AreClose(T a, T b) {
    return Abs(a - b) < static_cast<T>(0.000001);
}

//    
template<class T>
bool AreClose(T a, T b) {
    return a == b;
}
      
      





Também modificamos a função de impressão. Se ligarmos a.begin()



e a.end()



dissermos, assumimos esse a



contêiner.



#include <iostream>
#include <vector>

template<class T>
concept HasBeginEnd = 
    requires(T a) {
        a.begin();
        a.end();
    };

template<HasBeginEnd T>
void Print(std::ostream& out, const T& v) {
    for (const auto& elem : v) {
        out << elem << std::endl;
    }
}

template<class T>
void Print(std::ostream& out, const T& v) {
    out << v;
}
      
      





Novamente, este não é um exemplo ideal, uma vez que o contêiner não é apenas algo com begin



e end



, existem muitos mais requisitos impostos a ele. Mas já não é ruim.



É melhor usar um conceito pronto como is_floating_point_v



no exemplo anterior. Para um análogo de contêineres, a biblioteca padrão também tem um conceito - std::ranges::input_range



. Mas essa é uma história completamente diferente.



Teoria



É hora de entender qual é o conceito. Não há realmente nada complicado aqui:



conceito é o nome de uma restrição.



Nós o reduzimos a outro conceito, cuja definição já é significativa, mas pode parecer estranho:



Restrição é uma expressão clichê.



Grosso modo, as condições acima "ser um iterador" ou "ser um número de ponto flutuante" - essas são as restrições. Toda a essência da inovação reside precisamente nas limitações, e o conceito é apenas uma forma de se referir a elas.



A limitação mais simples é esta true



. Qualquer tipo combina com ele.



template<class T> concept C1 = true;
      
      





Operações booleanas e combinações de outras restrições estão disponíveis para restrições:



template <class T>
concept Integral = std::is_integral<T>::value;

template <class T>
concept SignedIntegral = Integral<T> &&
                         std::is_signed<T>::value;
template <class T>
concept UnsignedIntegral = Integral<T> &&
                           !SignedIntegral<T>;
      
      





Você pode usar expressões em restrições e até mesmo chamar funções. Mas as funções devem ser constexpr - elas são calculadas em tempo de compilação:



template<typename T>
constexpr bool get_value() { return T::value; }
 
template<typename T>
    requires (sizeof(T) > 1 && get_value<T>())
void f(T); // #1
 
void f(int); // #2
 
void g() {
    f('A'); //  #2.
}
      
      





E a lista de possibilidades não termina aí.



Há um ótimo recurso para restrições: verificar a exatidão da expressão - que ela compila sem erros. Veja a limitação Addable



. Está escrito entre colchetes a + b



. As condições de restrição são atendidas quando os valores a



e b



tipos T



permitem tal registro, ou seja, T



há uma determinada operação de adição:



template<class T>
concept Addable =
requires (T a, T b) {
    a + b;
};
      
      





Um exemplo mais complexo é a chamada de funções swap



e forward



. A restrição será executada quando este código for compilado sem erros:



template<class T, class U = T>
concept Swappable = requires(T&& t, U&& u) {
    swap(std::forward<T>(t), std::forward<U>(u));
    swap(std::forward<U>(u), std::forward<T>(t));
};
      
      





Outro tipo de restrição é a validação de tipo:



template<class T> using Ref = T&;
template<class T> concept C =
requires {
    typename T::inner; 
    typename S<T>;     
    typename Ref<T>;   
};
      
      





Uma restrição pode exigir não apenas a correção da expressão, mas também que o tipo de seu valor corresponda a algo. Aqui escrevemos:



  • expressão entre colchetes,
  • ->,



  • outra limitação.


template<class T> concept C1 =
requires(T x) {
    {x + 1} -> std::same_as<int>;
};
      
      





A limitação neste caso - same_as<int>





ou seja, o tipo da expressão x + 1



deve ser exatamente int



.



Observe que a seta é seguida pela restrição, não pelo tipo em si. Confira outro exemplo do conceito:



template<class T> concept C2 =
requires(T x) {
    {*x} -> std::convertible_to<typename T::inner>;
    {x * 1} -> std::convertible_to<T>;
};
      
      





Ele tem duas limitações. O primeiro indica que:



  • a expressão está *x



    correta;
  • o tipo está T::inner



    correto;
  • tipo é *x



    convertido paraT::inner.





Existem três requisitos em uma linha. O segundo indica que:



  • a expressão está x * 1



    sintaticamente correta;
  • seu resultado é convertido em T



    .


Quaisquer restrições podem ser formadas usando os métodos acima. Eles são muito divertidos e agradáveis, mas você rapidamente se cansaria deles e esqueceria se não pudesse usá-los. E você pode usar restrições e conceitos para qualquer coisa que ofereça suporte a modelos. Claro, os principais usos são funções e classes.



Então, descobrimos como escrever restrições , agora direi onde você pode escrevê-las .



Uma restrição de função pode ser escrita em três lugares diferentes:



//   class  typename   .
//   .
template<Incrementable T>
void f(T arg);

//    requires.       
//     .
//    .
template<class T>
requires Incrementable<T>
void f(T arg);

template<class T>
void f(T arg) requires Incrementable<T>;
      
      





E há uma quarta maneira, que parece bastante mágica:



void f(Incrementable auto arg);
      
      





Um modelo implícito é usado aqui. Até C ++ 20, eles estavam disponíveis apenas em lambdas. Você agora pode ser usado auto



em qualquer função assinatura: void f(auto arg)



. Além disso, um auto



nome de conceito é permitido antes disso , como no exemplo. A propósito, modelos explícitos agora estão disponíveis em lambdas, mas mais sobre isso mais tarde.



Uma diferença importante: quando escrevemos requires



, podemos escrever qualquer restrição e, em outros casos, apenas o nome do conceito.



Existem menos possibilidades para uma classe - apenas duas maneiras. Mas isso é o suficiente:



template<Incrementable T>
class X {};
template<class T>
requires Incrementable<T>
class Y {};
      
      





Anton Polukhin, que ajudou na preparação deste artigo, notou que a palavra requires



pode ser usada não apenas para declarar funções, classes e conceitos, mas também no corpo de uma função ou método. Por exemplo, é útil se você estiver escrevendo uma função que preenche um contêiner de um tipo anteriormente desconhecido:



template<class T> 
void ReadAndFill(T& container, int size) { 
    if constexpr (requires {container.reserve(size); }) { 
        container.reserve(size); 
    }

    //   
}
      
      





Esta função funcionará igualmente bem com ambos vector



, e com list



, e para a primeira, o método necessário em seu caso será chamado reserve



.



Útil requires



para static_assert



. Desta forma, você pode verificar o cumprimento não apenas das condições ordinárias, mas também da exatidão do código arbitrário, a presença de métodos e operações em tipos.



Curiosamente, um conceito pode ter vários parâmetros de modelo. Ao usar o conceito, você precisa especificar tudo, exceto um - aquele que estamos verificando para a restrição.



template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
 
template<Derived<Other> X>
void f(X arg);
      
      





O conceito tem Derived



dois parâmetros de modelo. Na declaração, f



indiquei um deles, e o segundo - a classe X



, que está marcada. Perguntou-se ao público qual parâmetro eu indiquei: T



ou U



; funcionou Derived<Other, X>



ou Derived<X, Other>



?



A resposta não é óbvia: é Derived<X, Other>



. Ao especificar um parâmetro Other



, especificamos um segundo parâmetro de modelo. Os resultados da votação divergiram:



  • respostas corretas - 8 (61,54%);
  • respostas erradas - 5 (38,46%).


Ao especificar os parâmetros do conceito, você precisa especificar tudo, exceto o primeiro, e o primeiro será verificado. Pensei por muito tempo por que o Comitê tomou tal decisão, e sugiro que você pense também. Escreva suas idéias nos comentários.



Então, eu disse a vocês como definir novos conceitos, mas nem sempre é necessário - já existem muitos na biblioteca padrão. Este slide mostra os conceitos encontrados no arquivo de cabeçalho <concepts>.







Isso não é tudo: existem conceitos para testar diferentes tipos de iteradores em <iterator>, <ranges> e outras bibliotecas.







Status







"Conceitos" estão por toda parte, mas não completamente no Visual Studio ainda:



  • GCC. Bem suportado desde a versão 10;
  • Clang. Suporte total na versão 10;
  • Estúdio visual. Suportado pelo VS 2019, mas não totalmente implementado requer.


Conclusão



Durante a transmissão, perguntamos ao público se eles gostaram desse recurso. Resultados da pesquisa:



  • Super recurso - 50 (92,59%)
  • Portanto, recurso - 0 (0,00%)
  • Pouco claro - 4 (7,41%)


A esmagadora maioria dos que votaram apreciou os conceitos. Eu também acho que esse é um recurso legal. Obrigado ao Comitê!



Os leitores da Habr, bem como os ouvintes do webinar, terão a oportunidade de avaliar as inovações.



All Articles