Primeira impressão de conceitos



Decidi lidar com o novo recurso C ++ 20 - conceitos.

Conceitos (ou conceitos , como escreve o Wiki de língua russa) são um recurso muito interessante e útil que há muito falta.

Essencialmente, ele está digitando para argumentos de modelo.

O principal problema dos modelos anteriores ao C ++ 20 é que você pode substituir qualquer coisa neles, incluindo algo para o qual eles não foram projetados. Ou seja, o sistema de gabaritos foi completamente não digitado. Como resultado, ocorreram mensagens de erro incrivelmente longas e completamente ilegíveis ao passar o parâmetro errado para o modelo. Eles tentaram combater isso com a ajuda de diferentes hacks de linguagem, que eu nem quero mencionar (embora eu tenha que lidar com isso).

Os conceitos são projetados para corrigir esse mal-entendido. Eles adicionam um sistema de digitação aos modelos e é muito poderoso. E agora, entendendo os recursos desse sistema, comecei a estudar os materiais disponíveis na Internet.

Francamente, estou um pouco chocado :) C ++ é uma linguagem já complicada, mas pelo menos há uma desculpa: aconteceu. A metaprogramação de modelos foi descoberta, não estabelecida, ao projetar uma linguagem. E então, ao desenvolver as próximas versões da linguagem, eles foram forçados a se adaptar a essa "descoberta", pois muitos códigos foram escritos no mundo. Os conceitos são uma oportunidade fundamentalmente nova. E, parece-me, alguma opacidade já está presente em sua implementação. Talvez isso seja uma consequência da necessidade de levar em consideração a enorme quantidade de recursos herdados? Vamos tentar descobrir isso ...

Informação geral


Um conceito é uma nova entidade de idioma baseada na sintaxe do modelo. Um conceito tem um nome, parâmetros e um corpo - um predicado que retorna um valor lógico constante (isto é, computado no estágio de compilação), dependendo dos parâmetros do conceito. Como isso:

template<int I> 
concept Even = I % 2 == 0;  

template<typename T>
concept FourByte = sizeof(T)==4;

Tecnicamente, os conceitos são muito semelhantes às expressões constexpr do modelo, como bool:

template<int I>
constexpr bool EvenX = I % 2 == 0; 

template<typename T>
constexpr bool FourByteX = sizeof(T)==4;

Você pode até usar conceitos em expressões comuns:

bool b1 = Even<2>; 

Usando


A idéia principal dos conceitos é que eles podem ser usados ​​em vez das palavras-chave ou nome do tipo de classe nos modelos. Como metatipos ("tipos para tipos"). Assim, a digitação estática é introduzida nos modelos.

template<FourByte T>
void foo(T const & t) {}

Agora, se usarmos int como parâmetro de modelo, o código na grande maioria dos casos será compilado; e se duplicar, será emitida uma mensagem de erro curta e compreensível. Digitação simples e clara de modelos, até agora tudo está ok.

requer


Esta é uma nova palavra-chave “contextual” C ++ 20 com um objetivo duplo: requer cláusula e requer expressão. Como será mostrado mais adiante, essa economia estranha de palavras-chave leva a alguma confusão.

requer expressão


Primeiro, considere requer expressão. A idéia é boa: essa palavra tem um bloco entre chaves, cujo código é avaliado para compilação. É verdade que o código não deve ser escrito em C ++, mas em uma linguagem especial, próxima ao C ++, mas com características próprias (essa é a primeira estranheza, era possível criar apenas código C ++).

Se o código estiver correto - requer que a expressão retorne verdadeiro, caso contrário, falso. O próprio código, é claro, nunca entra na geração de código, como expressões em sizeof ou decltype.

Infelizmente, a palavra é contextual e funciona apenas dentro de modelos, ou seja, fora do modelo, isso não é compilado:

bool b = requires { 3.14 >> 1; };

E no modelo - por favor:

template<typename T>
constexpr bool Shiftable = requires(T i) { i>>1; };

E vai funcionar:

bool b1 = Shiftable<int>; // true
bool b2 = Shiftable<double>; // false

O principal uso de requer expressão é criar conceitos. Por exemplo, é assim que você pode verificar a presença de campos e métodos em um tipo. Um caso muito popular.

template <typename T>
concept Machine = 
  requires(T m) {  //   `m` ,   Machine
	m.start();     //    `m.start()` 
	m.stop();      //   `m.stop()`
};  

A propósito, todas as variáveis ​​que podem ser necessárias no código testado (não apenas nos parâmetros do modelo) devem ser declaradas entre parênteses, requer expressão. Por alguma razão, declarar uma variável simplesmente não é possível.

A verificação de tipo dentro requer


É aqui que as diferenças entre requer código e C ++ padrão começam. Para verificar os tipos retornados, uma sintaxe especial é usada: o objeto é colocado entre colchetes, uma seta é colocada e, depois disso, é escrito um conceito que o tipo deve satisfazer. Além disso, o uso de tipos diretamente não é permitido.

Verifique se o retorno da função pode ser convertido para int:

requires(T v, int i) {
  { v.f(i) } -> std::convertible_to<int>;
}  

Verifique se a função de retorno é exatamente int:

requires(T v, int i) {
  { v.f(i) } -> std::same_as<int>; 
}  

(std :: same_as e std :: convertible_to são conceitos da biblioteca padrão).

Se você não incluir uma expressão cujo tipo está marcado entre chaves, o compilador não entenderá o que eles querem dele e interpretará a sequência inteira como uma expressão única que precisa ser verificada para compilação.

requer dentro requer


A palavra-chave requer tem um significado especial dentro requer expressões. Expressões requeridas aninhadas (já sem chaves) são verificadas não para compilação, mas para igualdade verdadeira ou falsa. Se essa expressão for falsa, a expressão anexa imediatamente será falsa (e outras análises de compilação serão interrompidas). Forma geral:

requires { 
  expression;         // expression is valid
  requires predicate; // predicate is true
};

Como predicado, por exemplo, conceitos previamente definidos ou características de tipo podem ser usados. Exemplo:

requires(Iter it) {
  //     (   Iter   *  ++)
  *it++;
 
  //    -  
  requires std::convertible_to<decltype(*it++), typename Iter::value_type>;
 
  //    -  
  requires std::is_convertible_v<decltype(*it++), typename Iter::value_type>;
}

Ao mesmo tempo, expressões requeridas aninhadas são permitidas com código entre colchetes, que é verificado quanto à validade. No entanto, se você simplesmente escrever uma expressão requerida dentro de outra, a expressão aninhada (tudo como um todo, incluindo a palavra-chave requer aninhada) será simplesmente verificada quanto à validade:

requires (T v) { 
  requires (typename T::value_type x) { ++x; }; //     , 
												//     !
};  

Portanto, surgiu uma forma estranha com duplo requer:

requires (T v) { 
  requires requires (typename T::value_type x) { ++x; }; //       "++x"
};  

Aqui está uma sequência de escape tão divertida de "requer".

A propósito, outra combinação de dois requer é esta cláusula de tempo (veja abaixo) e expressão:

template <typename T>
  requires requires(T x, T y) { bool(x < y); }
bool equivalent(T const& x, T const& y)
{
  return !(x < y) && !(y < x);
};

requer cláusula


Agora vamos para outro uso da palavra requer - declarar restrições de um tipo de modelo. Esta é uma alternativa ao uso de nomes de conceito em vez de nome de tipo. No exemplo a seguir, todos os três métodos são equivalentes:

//  require
template<typename Cont>
	requires Sortable<Cont>
void sort(Cont& container);

//   require (  )
template<typename Cont>
void sort(Cont& container) requires Sortable<Cont>;

//    typename
template<Sortable Cont>
void sort(Cont& container)  

A declaração de requisitos pode usar vários predicados combinados por operadores lógicos.

template <typename T>
  requires is_standard_layout_v<T> && is_trivial_v<T>
void fun(T v); 
 
int main()
{
  std::string s;
 
  fun(1);  // ok
  fun(s);  // compiler error
}

No entanto, basta inverter uma das condições, pois ocorre um erro de compilação:

template <typename T>
  requires is_standard_layout_v<T> && !is_trivial_v<T>
void fun(T v); 

Aqui está um exemplo que não será compilado

template <typename T>
  requires !is_trivial_v<T>
void fun(T v);	

A razão para isso são as ambiguidades que surgem ao analisar algumas expressões. Por exemplo, nesse modelo:

template <typename T> 
  requires (bool)&T::operator short unsigned int foo();

não está claro o que atribuir sem assinatura - ao operador ou ao protótipo da função foo (). Portanto, os desenvolvedores decidiram que, sem parênteses, apenas pistas de literais verdadeiros ou falsos, nomes de campos do tipo bool do valor do formulário, valor, T :: value, ns :: trait :: value, podem ser usados ​​como argumentos para requerer a cláusula. Nomes de conceito do tipo Conceito e requer expressões. Tudo o resto deve estar entre parênteses:

template <typename T>
  requires (!is_trivial_v<T>)
void fun(T v);

Agora, sobre os recursos predicados da cláusula


Considere outro exemplo.

template <typename T>
  requires is_trivial_v<typename T::value_type> 
void fun(T v); 

Neste exemplo, requer usa uma característica que depende do tipo value_type aninhado. Não se sabe antecipadamente se um tipo arbitrário possui um tipo aninhado que pode ser passado para o modelo. Se você passar, por exemplo, um tipo int simples para esse modelo, haverá um erro de compilação; no entanto, se tivermos duas especializações do modelo, não haverá erro; apenas outra especialização será escolhida.

template <typename T>
  requires is_trivial_v<typename T::value_type> 
void fun(T v) { std::cout << "1"; } 
 
template <typename T>
void fun(T v) { std::cout << "2"; } 
 
int main()
{
  fun(1);  // displays: "2"
}

Portanto, a especialização é descartada não apenas quando o predicado da cláusula require retorna false, mas também quando se mostra incorreto.

Os parênteses ao redor do predicado são um lembrete importante de que na cláusula exige o inverso do predicado não é o oposto do próprio predicado. Assim,

requires is_trivial_v<typename T::value_type> 

significa que a característica está correta e retorna verdadeira. Em que

!is_trivial_v<typename T::value_type> 

Significaria "a característica está correta e retorna falsa" A
inversão lógica real do primeiro predicado NÃO é ("a característica está correta e retorna verdadeira") == "a característica é INCORRETA ou retorna falsa" - isso é alcançado de uma maneira um pouco mais complicada - através de uma definição explícita do conceito:

template <typename T>
concept value_type_valid_and_trivial 
  = is_trivial_v<typename T::value_type>; 
 
template <typename T>
  requires (!value_type_valid_and_trivial<T>)
void fun(T v); 

Conjunção e Disjunção


Os operadores lógicos de conjunção e disjunção parecem normalmente, mas na verdade funcionam um pouco diferente do que no C ++ normal.

Considere dois trechos de código muito semelhantes.

O primeiro é um predicado sem parênteses:

template <typename T, typename U>
  requires std::is_trivial_v<typename T::value_type>
		|| std::is_trivial_v<typename U::value_type>
void fun(T v, U u); 

O segundo é com colchetes:

template <typename T, typename U>
  requires (std::is_trivial_v<typename T::value_type>
		 || std::is_trivial_v<typename U::value_type>)
void fun(T v, U u); 

A diferença está apenas entre parênteses. Mas, por causa disso, no segundo modelo, não existem duas restrições unidas por uma "cláusula de exigência", mas uma unida por uma OR lógica usual.

Essa diferença é a seguinte. Considere o código

std::optional<int> oi {};
int i {};
fun(i, oi);

Aqui, o modelo é instanciado pelos tipos int e std :: optional.

No primeiro caso, o tipo int :: value_type é inválido e a primeira limitação não é satisfeita.

Mas o tipo optional :: value_type é válido, o segundo traço retorna true e, como existe um operador OR entre as restrições, todo o predicado é satisfeito como um todo.

No segundo caso, essa é uma expressão única que contém um tipo inválido, pelo qual é inválido em geral e o predicado não é satisfeito. Então, colchetes simples mudam imperceptivelmente o significado do que está acontecendo.

Em conclusão


Obviamente, nem todos os recursos dos conceitos são mostrados aqui. Eu simplesmente não fui mais longe. Mas como uma primeira impressão - uma ideia muito interessante e uma implementação confusa um tanto estranha. E uma sintaxe engraçada com repetição exige, o que realmente confunde. Há realmente tão poucas palavras em inglês que você teve que usar uma palavra para fins completamente diferentes?

A idéia com código compilado é definitivamente boa. É até um pouco semelhante a "quase-citação" em macros de sintaxe. Mas valeu a pena misturar a sintaxe especial para verificar os tipos de retorno? IMHO, para isso, seria simplesmente necessário criar uma palavra-chave separada.

A mistura implícita dos conceitos “verdadeiro / falso” e “compila / não compila” em uma pilha e, como resultado, piadas com colchetes também estão erradas. Esses são conceitos diferentes e devem existir estritamente em diferentes contextos (embora eu entenda de onde veio - da regra SFINAE, onde código não compilado apenas excluiu silenciosamente a especialização da consideração). Mas se o objetivo dos conceitos é tornar o código o mais explícito possível, valeu a pena arrastar todas essas coisas implícitas para novos recursos?

O artigo foi escrito principalmente com base em
akrzemi1.wordpress.com/2020/01/29/requires-expression
akrzemi1.wordpress.com/2020/03/26/requires-clause
(há muito mais exemplos e recursos interessantes)
com minhas adições de Em outras fontes,
todos os exemplos podem ser verificados emwandbox.org

All Articles