CodeGym /Cursos /C++ SELF /SFINAE vs concepts: por que concepts são preferíveis

SFINAE vs concepts: por que concepts são preferíveis

C++ SELF
Nível 58, Lição 1
Disponível

1. Por que comparar SFINAE e concepts

Quando você começa a escrever funções "genéricas", a mente desenha alegremente a cena: "faço um template e ele vai funcionar para todos os tipos". Na prática o C++ lembra educadamente: nem todos os tipos sabem fazer as mesmas coisas. Um int não tem .size(), um std::vector<int> não tem operator+ (no sentido de "somar dois vetores e obter um novo"), e um std::string tem muitas coisas que números jamais terão. Por isso a linguagem precisa responder à pergunta: "qual função chamar para um dado tipo?" — e preferencialmente de forma que o humano não chore ao ler a mensagem do compilador.

Historicamente o C++ resolveu parte desse problema com um truque chamado SFINAE. Com C++20 surgiu uma ferramenta mais direta, legível e "amistosa" — concepts e requires. Nesta aula vamos explicar a ideia do SFINAE em termos conceituais (sem mergulhar no museu de metaprogramação), comparar com concepts e montar um pequeno trecho de código para nosso mini‑projeto educativo edu_utils.

SFINAE em termos simples

SFINAE significa Substitution Failure Is Not An Error. Ou seja, "falha na substituição (de tipo) — não é um erro". Soa quase como uma filosofia zen: o compilador não reclama, ele finge que nada aconteceu. A ideia é: quando o compilador substitui tipos em um template e percebe que alguma parte da declaração da função ficou inválida, ele não é obrigado a falhar imediatamente — pode simplesmente remover essa sobrecarga dos candidatos e tentar outras opções.

Uma formulação característica desse comportamento nos textos de padronização é: "a function shall not participate in overload resolution unless the expression … is well-formed" — ou seja: "shall not participate in overload resolution unless the expression … is well-formed".

Importante: SFINAE geralmente "funciona" apenas onde o compilador está exatamente escolhendo entre sobrecargas. Se você tem uma única função e ela não serve, você receberá um erro do tipo no matching function (o que, para ser sincero, muitas vezes é preferível a uma longa mensagem vinda do corpo do template).

SFINAE não é um if nem uma verificação em tempo de execução. É puramente sobre compilação: "o tipo encaixa na forma da função?"

2. Mini‑exemplo SFINAE: requisito escondido em decltype(...)

Para sentir por que SFINAE é frequentemente considerado "difícil de ler", basta olhar o truque clássico: "uma função existe somente se uma expressão for válida".

Suponha que no nosso mini‑projeto edu_utils exista uma função para "elevar ao quadrado". Queremos que ela funcione apenas para tipos onde a expressão x * x faz sentido.

#include <string>

template <class T>
auto square_sfinae(T x) -> decltype(x * x) {
    return x * x;
}

int main() {
    (void)square_sfinae(5);

    // (void)square_sfinae(std::string{"hi"}); // does not compile
}

Do ponto de vista do compilador acontece o seguinte: para T = int está tudo bem, porque x * x é válido. Para T = std::string a expressão x * x é inválida, então o tipo de retorno decltype(x * x) não pode ser deduzido — e essa sobrecarga "cai fora".

O problema para quem está aprendendo é simples: o contrato da função está escondido. Olhando só para o nome square_sfinae você não vê que a função exige operator*. Esse requisito está escondido no final da declaração, em decltype(...), como se tivesse vergonha de ser público.

O segundo problema são as mensagens do compilador. Mesmo em casos simples elas podem não ser muito amigáveis. E se a expressão dentro de decltype(...) for mais complexa, você pode receber um "romance" sobre como o compilador sofreu.

4. Os mesmos requisitos via concept: contrato visível na assinatura

Concepts tornam a mesma ideia direta: "o tipo deve satisfazer uma condição". Você não esconde o requisito num truque técnico, você o escreve explicitamente — e o compilador também passa a comunicar em termos de requisitos, não de "falha ao deduzir decltype em algum subsolo".

Vamos criar um concept Multipliable (nome simples e honesto: "multiplicável"):

template <class T>
concept Multipliable = requires(T x) {
    x * x;
};

template <Multipliable T>
T square_concept(T x) {
    return x * x;
}

int main() {
    (void)square_concept(5);

    // (void)square_concept(std::string{"hi"}); // Multipliable not satisfied
}

Agora o contrato é legível à primeira vista: square_concept funciona apenas para Multipliable. A mensagem de erro do compilador tende a ser algo como "constraint not satisfied" — geralmente muito mais fácil de entender do que "não foi possível deduzir o tipo em algum lugar".

Há ainda um ponto sutil mas importante: concepts limitam precisamente os argumentos de template, não os "valores dos parâmetros". Costuma-se dizer: "Concepts constrain template arguments, not parameters".

Ou seja, trata‑se de tipos em tempo de compilação, não do que existe dentro da variável em tempo de execução.

5. Sobrecargas e filtragem de candidatos: por que com concepts fica mais claro

Quando você escreve duas sobrecargas "para famílias diferentes de tipos", você na prática projeta uma pequena API: "se o usuário passou um número — faça assim; se passou um container — faça de outra forma". No estilo antigo isso era frequentemente feito com truques SFINAE para que a sobrecarga "inadequada" desaparecesse.

Com concepts isso parece quase linguagem natural (bom, linguagem natural para programadores).

Por exemplo, vamos adicionar em edu_utils uma função que imprime que tipo de número foi passado:

#include <concepts>
#include <iostream>

void print_kind(std::integral auto x) {
    std::cout << "integral: " << x << '\n';  // integral: 10
}

void print_kind(std::floating_point auto x) {
    std::cout << "floating: " << x << '\n';  // floating: 3.14
}

int main() {
    print_kind(10);
    print_kind(3.14);
}

Aqui o compilador escolhe a sobrecarga assim: entre os candidatos permanecem apenas aqueles cujas restrições são satisfeitas. Em essência é muito próximo de "filtrar candidatos" — é aquilo que SFINAE buscava por vias tortuosas.

Agora um ponto importante que muitos descobrem da maneira difícil: interseção de constraints. Se você criar duas sobrecargas que são válidas ao mesmo tempo, o compilador dirá "não sei o que você quis".

#include <concepts>

template <class T>
concept AnyNumber = std::integral<T> || std::floating_point<T>;

void f(AnyNumber auto) {
    // version A
}

void f(std::integral auto) {
    // version B
}

int main() {
    // f(10); // potential ambiguity: both match
}

Isso não é "um defeito dos concepts", é um sinal útil de design: você escreveu regras que colocam uma chamada em duas categorias ao mesmo tempo. O mesmo podia acontecer com SFINAE, mas lá você gastaria bem mais tempo para entender o que estava ocorrendo.

6. Por que concepts geralmente são preferíveis ao SFINAE

Quando se pergunta "o que é melhor", a resposta profissional é: "depende". Mas no C++ moderno, didático e aplicado, existe uma regra prática estável: se você tem concepts (C++20/23) — use concepts em vez dos truques SFINAE.

Critério SFINAE concepts / requires
Legibilidade do contrato Frequentemente escondido em
decltype
, parâmetros complexos e "remendos técnicos"
Contrato visível na assinatura:
template <MyConcept T>
ou
requires ...
Mensagens de compilação Frequentemente longas e sobre "internals do mecanismo" Geralmente mais curtas e sobre o "requisito não satisfeito"
Divisão em "blocos de requisitos" Possível, mas costuma ficar pesado Natural:
concept
é um bloco nomeado de requisito
Sobrecargas Funciona, mas o design fica mais difícil de ler Natural: constraints filtram candidatos
Adoção por desenvolvedores juniores Frequentemente vira "código que tem medo de tocar" Geralmente percebido como um contrato de API normal

Resumindo: SFINAE é como consertar uma bicicleta com fita isolante. Sim, às vezes dá para pedalar. Mas se você tem parafusos, chave allen e manual, é melhor montar a bicicleta direito.

7. Exemplo prático: pretty_print para números, strings e ranges

Agora vamos fazer um pequeno trecho que mostra o "estilo moderno" de sobrecargas com constraints e, ao mesmo tempo, demonstra como pensar sobre interseções. Imagine que no projeto edu_utils queremos a função pretty_print, que imprime:

  • inteiros como "int: ...",
  • reais como "double: ...",
  • strings como "string: ...",
  • qualquer range (range) como uma lista de elementos.

E sim, std::string é um range de caracteres, então sem uma sobrecarga específica uma string poderia ser impressa caractere a caractere. Vamos controlar esse comportamento: para strings teremos uma sobrecarga "especial" para que a saída fique mais legível.

Sobrecargas para números e strings

Começamos pelo óbvio. Aqui não há mágica: apenas dois concepts e uma sobrecarga não‑template para strings.

#include <concepts>
#include <iostream>
#include <string_view>

namespace edu {

void pretty_print(std::integral auto x) {
    std::cout << "int: " << x << '\n';       // int: 42
}

void pretty_print(std::floating_point auto x) {
    std::cout << "double: " << x << '\n';    // double: 3.5
}

void pretty_print(std::string_view s) {
    std::cout << "string: " << s << '\n';    // string: hello
}

} // namespace edu

Repare numa pequena "truque de engenharia": std::string_view é um parâmetro leve, e std::string se converte facilmente para ele. Assim não precisamos escrever sobrecargas separadas para std::string, const char* etc.

Sobrecarga para std::ranges::range

Agora adicionamos o tratamento de ranges. Não vamos entrar nos algoritmos reais de ranges — basta que um range possa ser iterado com begin/end e usado em um range‑for.

#include <iostream>
#include <ranges>

namespace edu {

void pretty_print(std::ranges::range auto const& r) {
    std::cout << "range: ";
    for (const auto& x : r) {
        std::cout << x << ' ';
    }
    std::cout << '\n'; // range: 1 2 3
}

} // namespace edu

Pode surgir a pergunta: "por que isso não conflita com strings?" A boa notícia é que temos a sobrecarga pretty_print(std::string_view), que não é template. Normalmente essa sobrecarga vence nas regras de seleção (é "mais concreta"), então uma string será impressa como string, não como sequência de caracteres.

Mini‑demo em main()

Vamos montar uma verificação pequena. O código é intencionalmente curto para que você possa copiá‑lo e testar sem se afogar em detalhes.

#include <vector>
#include <string>

int main() {
    edu::pretty_print(42);
    edu::pretty_print(3.5);

    std::string s = "hello";
    edu::pretty_print(s);

    std::vector<int> v{1, 2, 3};
    edu::pretty_print(v);
}

Se você está pensando: "Uau, isso realmente parece um conjunto de regras, não um ritual para invocar o compilador", então você captou a vantagem principal dos concepts.

Por que ainda vale a pena entender SFINAE

Pode parecer que SFINAE pode ser esquecido e viver feliz para sempre. No C++23 aplicado a novo código isso costuma ser verdade. Mas conhecer a ideia SFINAE é útil por três motivos.

  • Primeiro, você vai encontrar SFINAE em bases de código antigas, em artigos, em respostas no Stack Overflow e até em partes da biblioteca padrão (especialmente historicamente).
  • Segundo, a própria ideia "não participar na overload resolution se a expressão for inválida" é um modelo de pensamento fundamental. Concepts não anulam esse modelo, apenas lhe dão uma sintaxe bonita e mensagens de erro melhores.
  • Terceiro, às vezes você está limitado pelo compilador ou pelo padrão do projeto, e concepts podem não estar disponíveis. Nesse caso SFINAE continua sendo uma ferramenta prática. Mas se você estiver em C++23 e puder escolher — quase sempre é melhor optar por concepts.

8. Erros comuns

Erro #1: achar que SFINAE é "verificação em tempo de execução".
Algumas vezes quem começa acredita que SFINAE de alguma forma "se não cabe — tenta outra ramificação em tempo de execução". Não: tudo é decidido em compilação. Se o código compilou, em tempo de execução ninguém mais "troca" sobrecargas.

Erro #2: esconder requisitos no corpo da função e esperar mensagens claras.
Um template sem restrições aceita quase qualquer tipo até esbarrar numa operação não suportada. No fim o erro ocorre na instanciação e costuma ser assustador. No C++ moderno é melhor expor requisitos em um concept/requires, para que o contrato esteja na declaração.

Erro #3: escrever constraints "por excesso" e acidentalmente proibir tipos válidos.
Uma situação comum: você exige demais (por exemplo, "size() e push_back e operator[]"), embora no corpo da função você só use size(). Esse "contrato superprotector" torna a API incômoda e inesperadamente quebra reutilização.

Erro #4: criar constraints sobrepostos e obter ambiguidade de sobrecargas.
Quando duas sobrecargas são válidas ao mesmo tempo, o compilador diz claramente: "escolham vocês". Geralmente significa que as categorias da sua API se sobrepõem. Resolve‑se afinando os constraints ou adicionando uma sobrecarga "mais concreta" para o caso especial (como fizemos para strings com std::string_view).

Erro #5: tentar usar o estilo SFINAE como abordagem principal em C++23 "porque no código antigo é assim".
SFINAE é poderoso, mas tecnicamente pesado. Concepts não surgiram por modismo, mas para expressar as mesmas ideias de forma mais simples, legível e com mensagens de erro melhores. Se o projeto usa C++20/23 — normalmente não há razão para preferir os truques SFINAE ao invés de concept/requires.

1
Tarefa
C++ SELF, nível 58, lição 1
Bloqueado
Quadrado por contrato
Quadrado por contrato
1
Tarefa
C++ SELF, nível 58, lição 1
Bloqueado
Vitrine de exemplo
Vitrine de exemplo
1
Tarefa
C++ SELF, nível 58, lição 1
Bloqueado
Tamanho sem loop
Tamanho sem loop
1
Tarefa
C++ SELF, nível 58, lição 1
Bloqueado
Saída formatada
Saída formatada
Comentários
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION