/blog/cpp20_concepts
2024-08-25 · 4 min · Programmazione · TAG · C++ · Tutorial

C++20 Concepts

Introduzione

Questo articolo introduce i concept, una delle funzionalità più utili di C++20 per il codice generico. Vedremo la terminologia essenziale e come usarli per esprimere in modo esplicito i requisiti di un template.

Per un riferimento completo, cppreference raccoglie la documentazione su vincoli e concept.

Motivazione

La prima domanda è semplice: a cosa servono i concept?

Quando scriviamo codice vogliamo, quasi sempre, che gli algoritmi e le strutture dati che implementiamo siano generici, cioè utilizzabili con tipi di dato diversi. Vogliamo quindi un'unica soluzione generica, senza doverla reimplementare per tipi di dato specifici. Questo offre diversi vantaggi:

  • maggiore manutenibilità;
  • riutilizzo dello stesso codice con tipi diversi;
  • possibilità per chi usa l'API di fornire tipi personalizzati.

Gli esempi più comuni sono gli algoritmi generici e le strutture della libreria standard, come std::swap e std::vector.

In C++ questa astrazione viene espressa con un template, che può essere istanziato con tipi diversi. Spesso, però, il tipo non può essere scelto arbitrariamente: l'implementazione può richiedere operazioni o proprietà specifiche.

Supponiamo di avere un algoritmo che, dati due valori dello stesso tipo, voglia eseguire l'operazione di addizione binaria e restituire il risultato.

#include <iostream>#include <format>template <typename T>auto add(T const& a, T const& b) -> T {    return a + b;}auto main() -> int {    std::cout << std::format("{}\n", add(1, 2));    // std::cout << std::format("{}\n", add("foo", "bar")); -> compilation error    return 0;}

Il parametro generico T del template non è vincolato: dal punto di vista della compilazione può essere istanziato con qualsiasi tipo di dato. Tuttavia, se si richiede l'istanziazione di questa funzione con certi tipi di dato, il compilatore genererà un errore.

Questo avviene perché il template della funzione in questione richiede implicitamente che i tipi di dato passati come parametro debbano fornire l'operatore binario di addizione. Quindi passando un tipo di dato che non presenta l'operatore di addizione binaria, il compilatore riscontra l'impossibilità di generare la funzione.

In quale punto fallisce il compilatore?

Non nella dichiarazione del template, ma durante la sua istanziazione, nel punto in cui l'implementazione tenta di usare un'operazione non disponibile.

Questo produce messaggi di errore complessi da decifrare e causa parecchia frustrazione. In codebase medio-grandi, con strutture dati annidate, la situazione peggiora esponenzialmente.

Per risolvere questo inconveniente dobbiamo introdurre dei vincoli, così da definire in modo esplicito i requisiti dei parametri del template.

Terminologia

Modello (template)

Un modello è un costrutto che genera un tipo o una funzione normale in fase di compilazione in base agli argomenti forniti dall'utente per i parametri del modello. Gli argomenti di un modello possono essere vincolati.

Requisiti

Sono espressi tramite la parola chiave requires, che descrive le condizioni che un tipo o un'espressione devono soddisfare. Per i dettagli rimando alla documentazione di cppreference su requires.

Vincolo (constraint)

Un vincolo è un insieme di requisiti sugli argomenti di un modello.

Questi sono usati per:

  • Selezionare correttamente gli overloading delle funzioni.
  • Decidere la specializzazione più appropriata per un modello.

Concetti (concepts)

Un concetto è un predicato che racchiude un insieme di vincoli. Ogni concetto viene valutato in fase di compilazione e diventa parte dell'interfaccia di un modello in cui viene usato sotto forma di vincolo.

Inoltre:

  • Un tipo di dato che soddisfa tutti i requisiti (e quindi i vincoli) di un concetto si dice che modella tale concetto.
  • Un concetto che è composto da un altro concetto e da vincoli aggiuntivi si dice che rifinisce il concetto (o i concetti).

Sintassi

A seconda della complessità di un vincolo, possiamo usare tre sintassi diverse per esprimerlo. Tutte le definizioni di seguito sono equivalenti ed è possibile combinarle insieme. Si tenga presente che std::integral è un concetto predefinito.

Dichiarazione completa ed esplicita

Molto utile se si devono imporre vincoli multipli.

template <typename T, typename Q>    requires std::integral<T> and std::integral<Q>auto add(T const t, Q const q) {    return t + q;}

Dichiarazione intermedia

template <std::integral T, std::integral Q>auto add(T const t, Q const q) {    return t + q;}

Dichiarazione compatta

auto add(std::integral auto const t, std::integral auto const q) {    return t + q;}

Soluzione

Per risolvere il problema dichiariamo il concept Addable e lo applichiamo ai parametri della funzione add. In questo caso usiamo la forma compatta.

#include <iostream>#include <concepts>template <typename T> concept Addable = requires(T a, T b) { a + b; // requisito 1};auto add(Addable auto const t, Addable auto const q) {    return t + q;}auto main() -> int {    std::cout << add(5, 6) << std::endl;    //std::cout << add("foo", "bar") << std::endl;  -> compilation error    return 0;}

Il template rifiuta in fase di compilazione qualsiasi tipo che non soddisfi il requisito. Rispetto alla versione non vincolata, il compilatore può produrre un errore più vicino all'interfaccia della funzione e quindi più semplice da interpretare.

Conclusione

Abbiamo visto come usare concept e vincoli per rendere espliciti i requisiti dei template e ottenere errori di compilazione più comprensibili. Per approfondire il tema, cppreference offre una guida completa a vincoli e concept.

Ultimo aggiornamento 2024-08-25.
Sorgente dell’articolo content/blog/cpp20_concepts.

Autore

Nicolò è un software architect di Bergamo. Lavora su firmware ESP32, HMI, app Android native, backend, librerie software e integrazioni tra sistemi.

Prossimo articolo

2024-08-01
Recensione di openSUSE Tumbleweed

La mia esperienza con openSUSE Tumbleweed tra rolling release, KDE, stabilità e piccoli compromessi quotidiani.