Am 25. Februar sprach der Autor des Kurses "C ++ Developer" in Yandex. Praktische Arbeit Georgy Osipov sprach über die neue Stufe der C ++ - Sprache - den C ++ 20 Standard. Die Vorlesung bietet einen Überblick über alle wichtigen Neuerungen des Standards, erklärt, wie sie jetzt angewendet werden und wie sie nützlich sein können.
Bei der Vorbereitung des Webinars war es das Ziel, einen Überblick über alle wichtigen Funktionen von C ++ 20 zu geben. Daher erwies sich das Webinar als reichhaltig und dauerte fast 2,5 Stunden. Zur Vereinfachung haben wir den Text in sechs Teile unterteilt:
- Module und eine kurze Geschichte von C ++ .
- Operation "Raumschiff" .
- Konzepte.
- Bereiche.
- Coroutinen.
- Weitere Kern- und Standardbibliotheksfunktionen. Fazit.
Dies ist der dritte Teil, der die Konzepte und Einschränkungen in modernem C ++ behandelt.
Konzepte
Motivation
Generische Programmierung ist ein wesentlicher Vorteil von C ++. Ich kenne nicht alle Sprachen, aber ich habe so etwas noch nie auf dieser Ebene gesehen.
Die generische Programmierung in C ++ hat jedoch einen großen Nachteil: Fehler sind schmerzhaft. Stellen Sie sich ein einfaches Programm vor, das einen Vektor sortiert. Schauen Sie sich den Code an und sagen Sie mir, wo der Fehler liegt:
#include <vector>
#include <algorithm>
struct X {
int a;
};
int main() {
std::vector<X> v = { {10}, {9}, {11} };
//
std::sort(v.begin(), v.end());
}
Ich habe eine Struktur
X
mit einem Feld definiert
int
, einen Vektor mit Objekten dieser Struktur gefüllt und versuche, sie zu sortieren.
Ich hoffe, Sie haben das Beispiel gelesen und den Fehler gefunden. Ich werde die Antwort bekannt geben: Der Compiler glaubt, dass der Fehler in ... der Standardbibliothek liegt. Die Diagnoseausgabe ist ungefähr 60 Zeilen lang und zeigt einen Fehler irgendwo in der xutility-Hilfsdatei an. Es ist fast unmöglich, die Diagnose zu lesen und zu verstehen, aber C ++ - Programmierer tun dies - schließlich müssen Sie immer noch Vorlagen verwenden.
Der Compiler zeigt an, dass sich der Fehler in der Standardbibliothek befindet. Dies bedeutet jedoch nicht, dass Sie sofort an das Standardisierungskomitee schreiben müssen. Tatsächlich ist der Fehler immer noch in unserem Programm. Es ist nur so, dass der Compiler nicht klug genug ist, um es herauszufinden, und es tritt ein Fehler auf, wenn er in die Standardbibliothek geht. Das Auflösen dieser Diagnose führt zu einem Fehler. Aber:
- kompliziert,
- prinzipiell nicht immer möglich.
Formulieren wir das erste Problem der generischen Programmierung in C ++: Fehler bei der Verwendung von Vorlagen sind völlig unlesbar und werden nicht dort diagnostiziert, wo sie erstellt wurden, sondern in der Vorlage.
Ein weiteres Problem tritt auf, wenn abhängig von den Eigenschaften des Argumenttyps unterschiedliche Implementierungen einer Funktion verwendet werden müssen. Zum Beispiel möchte ich eine Funktion schreiben, die prüft, ob zwei Zahlen nahe genug beieinander liegen. Für ganze Zahlen reicht es aus, zu überprüfen, ob die Zahlen gleich sind, für Gleitkommazahlen reicht es aus, zu überprüfen, ob die Differenz kleiner als einige ε ist.
Das Problem kann mit dem SFINAE- Hack gelöst werden, indem zwei Funktionen geschrieben werden. Hack verwendet
std::enable_if
... Dies ist eine spezielle Vorlage in der Standardbibliothek, die einen Fehler enthält, wenn die Bedingung nicht erfüllt ist. Beim Instanziieren einer Vorlage wirft der Compiler Deklarationen mit einem Fehler weg:
#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;
}
In C ++ 17 kann ein solches Programm mit vereinfacht werden
if constexpr
, obwohl dies nicht in allen Fällen funktioniert.
Oder ein anderes Beispiel: Ich möchte eine Funktion schreiben
Print
, die alles druckt. Wenn ein Container an ihn übergeben wurde, werden alle Elemente gedruckt. Wenn nicht der Container, werden die übergebenen Elemente gedruckt. Ich werde es für alle Behälter definieren:
vector
,
list
,
set
und andere. Dies ist unpraktisch und nicht universell.
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 wird hier nicht mehr helfen. Es wird eher helfen, wenn Sie es versuchen, aber Sie müssen viel versuchen, und der Code wird sich als monströs herausstellen.
Das zweite Problem bei der generischen Programmierung besteht darin, dass es schwierig ist, verschiedene Implementierungen derselben Vorlagenfunktion für verschiedene Kategorien von Typen zu schreiben.
Beide Probleme können leicht gelöst werden, wenn Sie der Sprache nur eine Funktion hinzufügen - um die Vorlagenparameter einzuschränken . Beispielsweise muss der Vorlagenparameter ein Container oder Objekt sein, das Vergleiche unterstützt. Das ist das Konzept.
Was andere haben
Mal sehen, wie es in anderen Sprachen läuft. Der einzige, von dem ich weiß, dass er etwas Ähnliches hat, ist Haskell.
class Eq a where
(==) :: a -> a -> Bool
(/=) :: a -> a -> Bool
Dies ist ein Beispiel für eine Typklasse, die Unterstützung für die ausgebenden Operatoren "gleich" und "ungleich" benötigt
Bool
. In C ++ würde dasselbe wie folgt gemacht:
template<typename T>
concept Eq =
requires(T a, T b) {
{ a == b } -> std::convertible_to<bool>;
{ a != b } -> std::convertible_to<bool>;
};
Wenn Sie mit den Konzepten noch nicht vertraut sind, ist es schwierig zu verstehen, was geschrieben steht. Ich werde jetzt alles erklären.
In Haskell sind diese Einschränkungen erforderlich. Wenn Sie nicht sagen, dass es eine Operation geben wird
==
, können Sie sie nicht verwenden. In C ++ sind die Einschränkungen nicht streng. Auch wenn Sie im Konzept keine Operation angeben, kann sie dennoch verwendet werden - schließlich gab es zuvor überhaupt keine Einschränkungen, und neue Standards bemühen sich, die Kompatibilität mit den vorherigen nicht zu verletzen.
Beispiel
Ergänzen wir den Code des Programms, in dem Sie kürzlich nach einem Fehler gesucht haben:
#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());
}
Hier haben wir ein Konzept erstellt
IterToComparable
. Es zeigt, dass der Typ
T
ein Iterator ist, und zeigt auf Werte, die verglichen werden können. Das Ergebnis des Vergleichs ist etwas
bool
, das zum Beispiel in sich selbst konvertierbar ist
bool
. Eine ausführliche Erklärung wird etwas später gegeben, da Sie sich jetzt nicht mit diesem Code befassen müssen.
Die Einschränkungen sind übrigens schwach. Es heißt nicht, dass ein Typ alle Eigenschaften von Iteratoren erfüllen muss: Beispielsweise muss er nicht inkrementiert werden. Dies ist ein einfaches Beispiel, um die Möglichkeiten zu demonstrieren.
Das Konzept wurde anstelle eines Wortes
class
oder
typename
bei der Konstruktion von c verwendet
template
. Früher war es das
template<class InputIt>
, aber jetzt das Wort
class
ersetzt durch den Namen des Konzepts. Daher muss der Parameter
InputIt
die Bedingung erfüllen.
Wenn wir nun versuchen, dieses Programm zu kompilieren, wird der Fehler nicht in der Standardbibliothek angezeigt, sondern wie er sein sollte
main
. Und der Fehler ist verständlich, da er alle notwendigen Informationen enthält:
- Was ist passiert? Funktionsaufruf mit unerfüllter Einschränkung.
- Welche Bedingung ist nicht erfüllt?
IterToComparable<InputIt>
- Warum? Der Ausdruck ist
((* a) < (* b))
ungültig.
Die Compilerausgabe ist lesbar und benötigt 16 statt 60 Zeilen.
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')
Fügen wir der Struktur die fehlende Vergleichsoperation hinzu, und das Programm wird fehlerfrei kompiliert - das Konzept ist erfüllt:
struct X {
auto operator<=>(const X&) const = default;
int a;
};
In ähnlicher Weise können Sie das zweite Beispiel verbessern, p
enable_if
. Diese Vorlage wird nicht mehr benötigt. Wir verwenden stattdessen das Standardkonzept
is_floating_point_v<T>
. Wir erhalten zwei Funktionen: eine für Gleitkommazahlen, die andere für andere Objekte:
#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;
}
Wir ändern auch die Druckfunktion. Wenn Sie anrufen
a.begin()
und
a.end()
sagen, nehmen wir diesen
a
Container an.
#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;
}
Auch dies ist kein ideales Beispiel, da der Container nicht nur etwas ist
begin
und
end
viel mehr Anforderungen an ihn gestellt werden. Aber schon nicht schlecht.
Verwenden Sie am besten ein vorgefertigtes Konzept wie
is_floating_point_v
im vorherigen Beispiel. Für ein Analogon von Containern hat die Standardbibliothek auch ein Konzept -
std::ranges::input_range
. Aber das ist eine ganz andere Geschichte.
Theorie
Es ist Zeit zu verstehen, was das Konzept ist. Hier ist wirklich nichts kompliziert:
Konzept ist ein Name für eine Einschränkung.
Wir haben es auf ein anderes Konzept reduziert, dessen Definition bereits sinnvoll ist, aber es mag seltsam erscheinen:
Einschränkung ist ein Ausdruck auf der Kesselplatte.
Grob gesagt sind die oben genannten Bedingungen "ein Iterator sein" oder "eine Gleitkommazahl sein" - dies sind die Einschränkungen. Das ganze Wesen der Innovation liegt genau in den Grenzen, und das Konzept ist nur eine Möglichkeit, sich auf sie zu beziehen.
Die einfachste Einschränkung ist dies
true
. Jeder Typ passt zu ihm.
template<class T> concept C1 = true;
Boolesche Operationen und Kombinationen anderer Einschränkungen sind für Einschränkungen verfügbar:
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>;
Sie können Ausdrücke in Einschränkungen verwenden und sogar Funktionen aufrufen. Die Funktionen müssen jedoch constexpr sein - sie werden zur Kompilierungszeit berechnet:
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.
}
Und die Liste der Möglichkeiten endet hier nicht.
Es gibt eine großartige Funktion für Einschränkungen: Überprüfen der Richtigkeit des Ausdrucks - dass er fehlerfrei kompiliert wird. Schauen Sie sich die Einschränkung an
Addable
. Es ist in Klammern geschrieben
a + b
. Die Einschränkungsbedingungen sind erfüllt, wenn die Werte
a
und
b
Typen
T
einen solchen Datensatz zulassen, dh
T
eine bestimmte Additionsoperation hat:
template<class T>
concept Addable =
requires (T a, T b) {
a + b;
};
Ein komplexeres Beispiel ist das Aufrufen von Funktionen
swap
und
forward
. Die Einschränkung wird ausgeführt, wenn dieser Code fehlerfrei kompiliert wird:
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));
};
Eine andere Art von Einschränkung ist die Typvalidierung:
template<class T> using Ref = T&;
template<class T> concept C =
requires {
typename T::inner;
typename S<T>;
typename Ref<T>;
};
Eine Einschränkung erfordert möglicherweise nicht nur die Richtigkeit des Ausdrucks, sondern auch, dass der Typ seines Werts etwas entspricht. Hier schreiben wir:
- Ausdruck in geschweiften Klammern,
->,
- eine weitere Einschränkung.
template<class T> concept C1 =
requires(T x) {
{x + 1} -> std::same_as<int>;
};
Die Einschränkung in diesem Fall -
same_as<int>
Das heißt, der Typ des Ausdrucks
x + 1
muss genau sein
int
.
Beachten Sie, dass auf den Pfeil die Einschränkung folgt, nicht der Typ selbst. Schauen Sie sich ein weiteres Beispiel für das Konzept an:
template<class T> concept C2 =
requires(T x) {
{*x} -> std::convertible_to<typename T::inner>;
{x * 1} -> std::convertible_to<T>;
};
Es gibt zwei Einschränkungen. Der erste zeigt an, dass:
- der Ausdruck ist
*x
richtig; - der Typ ist
T::inner
korrekt; - Typ wird
*x
konvertiert inT::inner.
Es gibt drei Anforderungen in einer Zeile. Die zweite zeigt an, dass:
- der Ausdruck ist
x * 1
syntaktisch korrekt; - Das Ergebnis wird in konvertiert
T
.
Alle Einschränkungen können unter Verwendung der obigen Verfahren gebildet werden. Sie sind sehr lustig und unterhaltsam, aber Sie würden schnell genug davon bekommen und vergessen, wenn Sie sie nicht verwenden könnten. Und Sie können Einschränkungen und Konzepte für alles verwenden, was Vorlagen unterstützt. Natürlich sind die Hauptanwendungen Funktionen und Klassen.
Wir haben also herausgefunden, wie man Einschränkungen schreibt . Jetzt werde ich Ihnen sagen , wo Sie sie schreiben können .
Eine Funktionseinschränkung kann an drei verschiedenen Stellen geschrieben werden:
// 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>;
Und es gibt einen vierten Weg, der ziemlich magisch aussieht:
void f(Incrementable auto arg);
Hier wird eine implizite Vorlage verwendet. Bis C ++ 20 waren sie nur in Lambdas verfügbar. Sie können jetzt
auto
in jeder Funktionssignatur verwendet werden :
void f(auto arg)
. Darüber hinaus
auto
ist zuvor wie im Beispiel ein Konzeptname zulässig. Übrigens sind jetzt explizite Vorlagen in Lambdas verfügbar, aber dazu später mehr.
Ein wichtiger Unterschied: Wenn wir schreiben
requires
, können wir jede Einschränkung und in anderen Fällen nur den Namen des Konzepts aufschreiben.
Es gibt weniger Möglichkeiten für eine Klasse - nur zwei Möglichkeiten. Das reicht aber:
template<Incrementable T>
class X {};
template<class T>
requires Incrementable<T>
class Y {};
Anton Polukhin, der bei der Erstellung dieses Artikels half, bemerkte, dass das Wort
requires
nicht nur bei der Deklaration von Funktionen, Klassen und Konzepten verwendet werden kann, sondern auch direkt im Körper einer Funktion oder Methode. Zum Beispiel ist es praktisch, wenn Sie eine Funktion schreiben, die einen Container eines zuvor unbekannten Typs füllt:
template<class T>
void ReadAndFill(T& container, int size) {
if constexpr (requires {container.reserve(size); }) {
container.reserve(size);
}
//
}
Diese Funktion funktioniert mit beiden gleich gut
vector
, und mit
list
und für die erste wird die in ihrem Fall benötigte Methode aufgerufen
reserve
.
Nützlich
requires
für
static_assert
. Auf diese Weise können Sie überprüfen, ob nicht nur normale Bedingungen erfüllt sind, sondern auch die Richtigkeit von beliebigem Code, das Vorhandensein von Methoden und Operationen in Typen.
Interessanterweise kann ein Konzept mehrere Vorlagenparameter haben. Wenn Sie das Konzept verwenden, müssen Sie alles außer einem angeben - dem, das wir auf die Einschränkung prüfen.
template<class T, class U>
concept Derived = std::is_base_of<U, T>::value;
template<Derived<Other> X>
void f(X arg);
Das Konzept verfügt über
Derived
zwei Vorlagenparameter. In der Erklärung habe
f
ich eine davon angegeben, und die zweite - die Klasse
X
, die überprüft wird. Das Publikum wurde gefragt, welchen Parameter ich angegeben habe:
T
oder
U
; hat es funktioniert
Derived<Other, X>
oder
Derived<X, Other>
?
Die Antwort ist nicht offensichtlich: es ist
Derived<X, Other>
. Bei der Angabe eines Parameters
Other
haben wir einen zweiten Vorlagenparameter angegeben. Die Abstimmungsergebnisse gingen auseinander:
- richtige Antworten - 8 (61,54%);
- falsche Antworten - 5 (38,46%).
Wenn Sie die Parameter des Konzepts angeben, müssen Sie alles außer dem ersten angeben, und der erste wird überprüft. Ich habe lange darüber nachgedacht, warum der Ausschuss eine solche Entscheidung getroffen hat, und ich schlage vor, dass Sie auch darüber nachdenken. Schreiben Sie Ihre Ideen in die Kommentare.
Also habe ich Ihnen gesagt, wie man neue Konzepte definiert, aber das ist nicht immer notwendig - es gibt bereits viele davon in der Standardbibliothek. Diese Folie zeigt die Konzepte in der Header-Datei <concepts>.
Das ist noch nicht alles: Es gibt Konzepte zum Testen verschiedener Arten von Iteratoren in <iterator>, <ranges> und anderen Bibliotheken.
Status
"Konzepte" gibt es überall, aber noch nicht vollständig in Visual Studio:
- GCC. Gut unterstützt seit Version 10;
- Clang. Volle Unterstützung in Version 10;
- Visual Studio. Unterstützt von VS 2019, aber nicht vollständig implementiert erfordert.
Fazit
Während der Sendung haben wir das Publikum gefragt, ob ihnen diese Funktion gefällt. Umfrageergebnisse:
- Super Feature - 50 (92,59%)
- Also so Feature - 0 (0,00%)
- Unklar - 4 (7,41%)
Die überwiegende Mehrheit der Wähler schätzte die Konzepte. Ich denke auch, dass dies eine coole Funktion ist. Danke an das Komitee!
Die Leser von Habr sowie die Zuhörer von Webinaren erhalten die Möglichkeit, die Innovationen zu bewerten.