CodeGym /Kurse /C++ SELF /SFINAE vs concepts: warum concepts vorzuziehen sind

SFINAE vs concepts: warum concepts vorzuziehen sind

C++ SELF
Level 58, Lektion 1
Verfügbar

1. Warum überhaupt SFINAE und concepts vergleichen

Wenn man anfängt, „universelle“ Funktionen zu schreiben, malt das Hirn fröhlich das Bild: „ich mache ein Template, und es funktioniert für alle Typen“. In der Praxis erinnert C++ höflich daran: nicht alle Typen können dasselbe. Bei int gibt es kein .size(), bei std::vector<int> gibt es kein operator+ (im Sinne „zwei Vektoren addieren und ein neues Ergebnis erhalten“), und std::string hat viele Dinge, die Zahlen niemals haben werden. Deshalb muss die Sprache die Frage beantworten können: „Welche Funktion soll für einen gegebenen Typ aufgerufen werden?“ — am besten so, dass Menschen beim Lesen der Compilerfehler nicht weinen.

Historisch löste C++ einen Teil dieses Problems mit einer Technik namens SFINAE. Mit C++20 kam ein direkteres, lesbareres und „freundlicheres“ Werkzeug — concepts und requires. In dieser Vorlesung erklären wir die Idee von SFINAE auf konzeptioneller Ebene (ohne in das Museum der Metaprogrammierung abzutauchen), vergleichen mit concepts und bauen ein kleines Stück Code für unser Lehr‑Miniprojekt edu_utils.

SFINAE in verständlicher Form

SFINAE steht für Substitution Failure Is Not An Error. Das heißt „Substitutionsfehler (bei Typen) sind kein Fehler“. Klingt fast wie Zen‑Philosophie: der Compiler schimpft nicht, sondern tut so, als wäre nichts geschehen. Die Idee ist: wenn der Compiler Typen in ein Template einsetzt und feststellt, dass ein Teil der Funktionsdeklaration inkorrekt geworden ist, dann ist er nicht gezwungen, sofort mit einem Fehler abzubrechen — er kann einfach diese Überladung aus den Kandidaten entfernen und andere Optionen versuchen.

Eine sehr typische Formulierung dieses Verhaltens in Normtexten lautet: „eine Funktion beteiligt sich nicht an der Überladungsauflösung, wenn der Ausdruck inkorrekt ist“ — also: „shall not participate in overload resolution unless the expression … is well-formed“.

Wichtig: SFINAE „funktioniert“ normalerweise nur dort, wo der Compiler tatsächlich aus Überladungen auswählt. Wenn Sie genau eine Funktion haben und diese nicht passt, erhalten Sie den Fehler no matching function (was, ehrlich gesagt, oft besser ist als eine lange Fehlermeldung aus dem Inneren des Template‑Körpers).

SFINAE ist kein if und keine Laufzeitprüfung. Es ist reine Kompilierzeitlogik: „passt der Typ zur Funktionsform“.

2. Mini‑Beispiel SFINAE: die Anforderung ist in decltype(...) versteckt

Um nachzuvollziehen, warum SFINAE oft als „unleserlich“ gilt, reicht ein klassischer Lehrtrick: „die Funktion existiert nur, wenn ein Ausdruck wohlgeformt ist“.

Angenommen, in unserem Mini‑Projekt edu_utils gibt es eine Funktion zum „Quadratziehen“. Wir wollen, dass sie nur für Typen funktioniert, bei denen der Ausdruck x * x Sinn ergibt.

#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"}); // kompiliert nicht
}

Aus Sicht des Compilers passiert ungefähr Folgendes: für T = int ist alles gut, weil x * x wohlgeformt ist. Für T = std::string ist der Ausdruck x * x nicht gültig, deshalb kann der Rückgabewert decltype(x * x) nicht bestimmt werden — und diese Überladung fällt weg.

Das Problem für Einsteiger ist simpel: der Vertrag der Funktion ist versteckt. Beim Blick auf den Namen square_sfinae sieht man nicht, dass die Funktion operator* verlangt. Diese Anforderung ist im Schwanz der Deklaration versteckt, in decltype(...), als schäme sie sich, öffentlich zu sein.

Ein zweites Problem sind Compilerfehlermeldungen. Selbst in einfachen Fällen können sie nicht sehr freundlich sein. Wenn der Ausdruck in decltype(...) komplexer ist, erhält man schnell einen „Roman“ darüber, wie sehr der Compiler gelitten hat.

4. Dieselben Anforderungen per concept: der Vertrag steht in der Signatur

Concepts machen dieselbe Idee explizit: „der Typ muss eine Bedingung erfüllen“. Sie verstecken die Anforderung nicht in einem technischen Trick, sondern schreiben sie klar — und der Compiler kommuniziert ebenfalls in Begriffen von Anforderungen, nicht in Ausdrücken wie „decltype ist irgendwo gescheitert“.

Wir definieren ein concept Multipliable (ein einfacher, ehrlicher Name: „lässt sich multiplizieren“):

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 nicht erfüllt
}

Jetzt ist der Vertrag für das Auge lesbar: square_concept funktioniert nur für Multipliable. Die Compilerfehlermeldung wird in der Regel als „Constraint not satisfied“ formuliert — meist viel einfacher zu verdauen als „irgendwo konnte ein Typ nicht abgeleitet werden“.

Es gibt noch einen subtilen, aber wichtigen Aspekt: concepts beschränken genau die Template‑Argumente, nicht „Werte der Parameter“. Das wird oft so ausgedrückt: „Concepts constrain template arguments, not parameters“.

Das bedeutet: es geht um Typen zur Kompilierzeit, nicht um die konkreten Laufzeitwerte in einer Variablen.

5. Überladungen und Kandidatenfilter: warum mit concepts klarer

Wenn Sie zwei Überladungen für „verschiedene Familien von Typen“ schreiben, entwerfen Sie im Grunde ein kleines API: „Wenn der Nutzer eine Ganzzahl übergibt — mach so, wenn ein Container — mach anders“. Im alten Stil wurde das oft mit SFINAE‑Tricks umgesetzt, damit die „ungeeignete“ Überladung verschwindet.

Mit concepts liest sich das fast wie normale Sprache (naja, wie die Sprache eines Programmierers).

Zum Beispiel fügen wir in edu_utils eine Funktion hinzu, die ausgibt, welche Art von Zahl wir bekommen haben:

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

Hier wählt der Compiler die Überladung so: aus den Kandidaten bleiben nur diejenigen, deren Einschränkungen erfüllt sind. In der Bedeutung ist das sehr nah an einem „Kandidatenfilter“ — genau das, was SFINAE über Umwege erreichen wollte.

Und nun ein wichtiger Punkt, an dem sich viele die Stirn stoßen: Schnittmengen von Constraints. Wenn zwei Überladungen gleichzeitig passen, sagt der Compiler: „ich verstehe nicht, was Sie wollten“.

#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); // potenzielle Mehrdeutigkeit: beide passen
}

Das ist kein „Fehler von concepts“, sondern ein nützlicher Hinweis zur API‑Gestaltung: Sie haben Regeln so geschrieben, dass ein Aufruf in zwei Kategorien fällt. Dasselbe hätte man mit SFINAE erreichen können, aber dort hätten Sie mehr Zeit gebraucht, um überhaupt zu verstehen, was geschieht.

6. Warum concepts üblicherweise SFINAE vorgezogen werden

Auf die Frage „was ist besser“ möchte man als erwachsener Ingenieur antworten: „kommt drauf an“. Aber im Lehrbetrieb und im praktischen modernen C++ gibt es eine ziemlich stabile Regel: wenn concepts (C++20/23) verfügbar sind — verwenden Sie concepts, statt mit SFINAE‑Tricks zu arbeiten.

Kriterium SFINAE concepts / requires
Lesbarkeit des Vertrags Oft versteckt in
decltype
, kniffligen Parametern, „technischen“ Workarounds
Vertrag sichtbar in der Signatur:
template <MyConcept T>
oder
requires ...
Compilerfehlermeldungen Oft lang und „über interne Mechanismen“ Gewöhnlich kürzer und „über nicht erfüllte Anforderungen“
Aufteilung in „Bausteine von Anforderungen“ Möglich, aber meist umständlich Natürlich:
concept
ist genau so ein benannter Baustein
Überladungen Funktioniert, aber das Design ist schwerer zu lesen Natürlich: constraints filtern Kandidaten
Unterstützung durch Neulinge im Team Wird oft zu „Code, den man nicht anfassen will“ Wird eher als normaler API‑Vertrag verstanden

Kurz gesagt: SFINAE ist wie ein Fahrrad mit Isolierband reparieren. Ja, manchmal kommt man ans Ziel. Aber wenn man gute Schrauben, einen Inbusschlüssel und eine Anleitung hat, ist es besser, das Fahrrad richtig zusammenzubauen.

7. Praktisches Beispiel: pretty_print für Zahlen, Strings und Bereiche

Jetzt bauen wir ein sauberes Mini‑Stück, das den „modernen Stil“ von Überladungen mit Constraints zeigt und gleichzeitig demonstriert, wie man über Schnittmengen nachdenkt. Angenommen, im Projekt edu_utils wollen wir eine Funktion pretty_print, die Folgendes ausgibt:

  • ganzzahlige Werte als "int: ...",
  • Gleitkommazahlen als "double: ...",
  • Strings als "string: ...",
  • jede Range (range) als Liste von Elementen.

Und ja, std::string ist eine Range von Zeichen, sodass ohne spezielle Überladung ein String zeichenweise gedruckt würde. Wir machen dieses Verhalten steuerbar: für Strings gibt es eine eigene „spezielle“ Überladung, damit alles menschenfreundlich aussieht.

Überladungen für Zahlen und Strings

Wir beginnen mit dem Offensichtlichen. Hier ist keine Magie: einfach zwei concepts und eine nicht‑templatisierte Überladung für 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

Hier ist ein kleiner „Ingenieurtrick“ nützlich zu beachten: std::string_view ist ein leichter Parameter, und std::string lässt sich gut darauf konvertieren. So müssen wir nicht getrennte Überladungen für std::string, const char* usw. schreiben.

Überladung für std::ranges::range

Nun fügen wir die Behandlung von Ranges hinzu. Wir steigen nicht in echte ranges‑Algorithmen ein; es reicht, dass man eine Range mit begin/end durchlaufen oder einen range‑for verwenden kann.

#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

Hier stellt sich die Frage: „Warum kollidiert das nicht mit Strings?“ Die gute Nachricht ist: wir haben die Überladung pretty_print(std::string_view), die nicht templatebasiert ist. In der Regel gewinnt eine solche konkretere Überladung bei der Auswahl, daher wird ein String als String gedruckt und nicht als Folge von Zeichen.

Mini‑Demo in main()

Wir bauen eine kleine Überprüfung. Der Code ist bewusst kurz, damit Sie ihn in eine Sandbox kopieren können, ohne in Details zu versinken.

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

Wenn Sie jetzt denken: „Wow, das liest sich wie eine Menge Regeln und nicht wie ein Ritual, um den Compiler zu beschwören“ — dann haben Sie den Hauptvorteil von concepts verstanden.

Warum es dennoch nützlich ist, SFINAE zu verstehen

Es mag so wirken, als könne man SFINAE jetzt vergessen und glücklich weiterleben. In praktischem C++23 wird für neuen Code oft genau so verfahren. Aber die Kenntnis von SFINAE ist aus drei Gründen nützlich.

  • Erstens werden Sie SFINAE in alten Codebasen, in Artikeln, auf Stack Overflow und sogar in Teilen der Standardbibliothek (historisch bedingt) antreffen.
  • Zweitens ist das Grundmodell „nicht an der Überladungsauflösung teilnehmen, wenn ein Ausdruck nicht wohlgeformt ist“ ein fundamentales Denkmodell. Concepts heben dieses Modell nicht auf; sie geben ihm eine schöne Syntax und bessere Fehlermeldungen.
  • Drittens sind Sie manchmal durch Compiler oder Projektstandard eingeschränkt, und concepts sind vielleicht nicht verfügbar. Dann bleibt SFINAE ein brauchbares Werkzeug. Wenn Sie jedoch C++23 verwenden können — fast immer ist concepts die bessere Wahl.

8. Typische Fehler

Fehler Nr. 1: anzunehmen, SFINAE sei eine Laufzeitprüfung.
Manche Neulinge denken, SFINAE würde zur Laufzeit „eine andere Zweigwahl“ treffen, wenn etwas nicht passt. Nein: alles wird zur Kompilierzeit entschieden. Wenn der Code kompiliert, wechselt zur Laufzeit niemand mehr zwischen Überladungen.

Fehler Nr. 2: Anforderungen im Funktionskörper verstecken und hoffen, dass die Fehlermeldungen sinnvoll sind.
Ein Template ohne Einschränkungen akzeptiert fast jeden Typ, bis eine nicht unterstützte Operation verwendet wird. Dann tritt der Fehler bei der Instanziierung auf und sieht furchteinflößend aus. Im modernen C++ ist es besser, Anforderungen in ein concept/requires zu ziehen, damit der Vertrag in der Deklaration steht.

Fehler Nr. 3: Constraints „für alle Fälle“ schreiben und dadurch korrekte Typen versehentlich ausschließen.
Sehr häufig verlangt man zu viel (z. B. „sowohl .size(), als auch push_back, und operator[]“), obwohl die Funktion nur .size() nutzt. So ein „Übervorsicht‑Vertrag“ macht APIs unpraktisch und bricht Wiederverwendbarkeit.

Fehler Nr. 4: sich überschneidende Constraints erzeugen Überladungsmehrdeutigkeit.
Wenn zwei Überladungen gleichzeitig passen, sagt der Compiler ehrlich: „treffen Sie eine Auswahl“. Das deutet meist darauf hin, dass die Kategorien Ihres APIs sich überschneiden. Abhilfe schafft eine genauere Einschränkung der Constraints oder eine zusätzliche, „konkretere“ Überladung für Spezialfälle (wie wir es mit Strings über std::string_view gemacht haben).

Fehler Nr. 5: den SFINAE‑Stil als Hauptansatz in C++23 weiterverwenden, nur weil alter Code so geschrieben ist.
SFINAE ist mächtig, aber technisch aufwändig. Concepts wurden nicht aus Modegründen eingeführt, sondern damit sich dieselben Ideen einfacher ausdrücken, besser lesen und freundlichere Fehlermeldungen liefern. Wenn ein Projekt C++20/23 nutzt — meist besteht kein Grund, explizit SFINAE‑Tricks anstelle von concept/requires zu wählen.

1
Aufgabe
C++ SELF, Level 58, Lektion 1
Gesperrt
Quadrat gemäß Vertrag
Quadrat gemäß Vertrag
1
Aufgabe
C++ SELF, Level 58, Lektion 1
Gesperrt
Datenvitrine
Datenvitrine
1
Aufgabe
C++ SELF, Level 58, Lektion 1
Gesperrt
Größe ohne Schleife
Größe ohne Schleife
1
Aufgabe
C++ SELF, Level 58, Lektion 1
Gesperrt
Schöne Ausgabe
Schöne Ausgabe
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION