In diesem Artikel werde ich Ihnen eine der Curry-Optionen und die teilweise Anwendung der Funktionen in C ++ erläutern, die mein persönlicher Favorit ist. Ich werde auch meine eigene Pilotimplementierung dieser Sache zeigen und den Punkt des Curryens ohne komplexe mathematische Formel erklären, was es für Sie wirklich einfach macht. Wir werden auch sehen, was sich unter der Haube der kari.hpp- Bibliothek befindet, die wir für Curry-Funktionen verwenden werden. Wie auch immer, es gibt viele faszinierende Dinge im Inneren, also willkommen!
Currying
Also, was ist Curry? Ich denke, es ist eines dieser Wörter, die Sie ständig von Haskell- Programmierern hören (natürlich nach der Monade ). Im Wesentlichen ist die Definition des Begriffs ziemlich einfach, sodass diejenigen Leser, die bereits über ML- Sprachen oder Haskell geschrieben haben oder von anderswo wissen, was dies bedeutet, diesen Abschnitt gerne überspringen können.
Currying - ist die Technik zum Transformieren einer Funktion, die N Argumente in eine Funktion umwandelt, die ein einzelnes Argument verwendet und die Funktion des nächsten Arguments zurückgibt. Sie wird fortgesetzt und eins, bis die Funktion des letzten Arguments zurückgegeben wird, die dargestellt werden soll das Gesamtergebnis. Ich denke, es hilft, wenn ich Ihnen Beispiele zeige:
int sum2(int lhs, int rhs) { return lhs + rhs; }
Hier haben wir eine binäre Additionsfunktion. Und was ist, wenn wir daraus eine einzelne Variablenfunktion machen wollen? Es ist eigentlich sehr einfach:
auto curried_sum2(int lhs) { return [=](int rhs) { return sum2(lhs, rhs); }; }
Nein, was haben wir gemacht? Wir haben einen Wert basierend auf einem einzelnen Argument namens Lambda verwendet, das wiederum das zweite Argument verwendet und die Addition selbst ausführt. Infolgedessen können wir die Curry-Funktion curried_sum2
auf unsere Argumente anwenden:
Und das ist eigentlich der springende Punkt beim Curry-Betrieb. Natürlich ist es möglich, dies mit Funktionen jeder Art zu tun - es wird absolut genauso funktionieren. Wir werden jedes Mal eine Curry-Funktion von N-1-Argumenten zurückgeben, wenn wir den Wert von einem anderen Argument übernehmen:
auto sum3(int v1, int v2, int v3) { return v1 + v2 + v3; } auto curried_sum3(int v1) { return [=](int v2){ return [=](int v3){ return sum3(v1, v2, v3); }; }; }
Teilanwendung
Teilanwendung - ist eine Möglichkeit, Funktionen von N Argumenten aufzurufen, wenn sie nur einen Teil der Argumente übernehmen und eine andere Funktion der verbleibenden Argumente zurückgeben.
In diesem Zusammenhang sollte beachtet werden, dass dieser Prozess in Sprachen wie Haskell automatisch hinter dem Rücken eines Programmierers funktioniert. Wir versuchen hier, es explizit auszuführen, sum3
Unsere sum3
Funktion wie sum3(38,3)(1)
: sum3(38,3)(1)
oder vielleicht so: sum3(38)(3,1)
. Wenn eine Funktion eine andere Funktion zurückgibt, die aktuell ist, kann sie darüber hinaus auch über die Liste der Argumente der ersten Funktion aufgerufen werden. Sehen wir uns das Beispiel an:
int boo(int v1, int v2) { return v1 + v2; } auto foo(int v1, int v2) { return kari::curry(boo, v1 + v2); }
Wir haben hier tatsächlich ein wenig Vorsprung vor uns und zeigen ein Beispiel für die Verwendung von kari.hpp. Ja, das tut es.
Ziele setzen
Bevor wir etwas schreiben, ist es notwendig (oder wünschenswert) zu verstehen, was wir am Ende haben wollen. Und wir möchten die Möglichkeit haben, jede Funktion, die in C ++ aufgerufen werden kann, zu curry und teilweise anzuwenden. Welches sind:
- Lambdas (einschließlich generischer)
- Funktionsobjekte (Funktoren)
- Funktionen jeder Art (einschließlich Vorlagen)
- verschiedene Funktionen
- Methoden einer Klasse
Variadische Funktionen können durch Angabe einer genauen Anzahl von Argumenten, die wir curry möchten, ausgeführt werden. Eine Standardinteraktion mit std :: bind und seinen Ergebnissen ist ebenfalls wünschenswert. Und natürlich brauchen wir die Möglichkeit, Funktionen mit mehreren Variablen anzuwenden und verschachtelte Funktionen aufzurufen, so dass es den Anschein hat, als hätten wir mit einer Curry-Funktion gearbeitet.
Und wir dürfen auch die Leistung nicht vergessen. Wir müssen die Rechenkosten für Wrapper, die Übertragung von Argumenten und deren Speicherung minimieren. Dies bedeutet, dass wir umziehen müssen, anstatt zu kopieren, nur das speichern, was wir wirklich benötigen, und die Daten (mit weiterer Entfernung) so schnell wie möglich zurückgeben müssen.
Autor, Sie haben versucht, std::bind
one erneut zu erfinden!
Ja und nein. std::bind
ist zweifellos ein mächtiges und bewährtes Werkzeug, und ich habe nicht vor, seinen Mörder oder seine Alternative zu schreiben. Ja, es kann zum Currying und zur expliziten Teilanwendung verwendet werden (wobei genau angegeben wird, welche Argumente wir anwenden, wo und wie viele). Aber es ist sicher nicht der bequemste Ansatz, ganz zu schweigen davon, dass er nicht immer anwendbar ist, da wir die Funktionsvielfalt kennen und abhängig davon spezifische Bindungen schreiben müssen. Zum Beispiel:
int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; }
API
namespace kari { template < typename F, typename... Args > constexpr decltype(auto) curry(F&& f, Args&&... args) const; template < typename F, typename... Args > constexpr decltype(auto) curryV(F&& f, Args&&... args) const; template < std::size_t N, typename F, typename... Args > constexpr decltype(auto) curryN(F&& f, Args&&... args) const; template < typename F > struct is_curried; template < typename F > constexpr bool is_curried_v = is_curried<F>::value; template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename... As > constexpr decltype(auto) operator()(As&&... as) const; }; }
kari::curry(F&& f, Args&&... args)
Gibt ein Funktionsobjekt vom Typ curry_t
(eine Curry-Funktion) mit angewendeten optionalen Argumenten args
oder mit dem Ergebnis der Anwendung der Argumente auf die angegebene Funktion f
(ist die Funktion null oder sind die übertragenen Argumente ausreichend, um sie aufzurufen).
Wenn der Parameter f
die Funktion enthält, die bereits ausgeführt wurde, gibt er seine Kopie mit den angewendeten Argumenten args
.
kari::curryV(F&& f, Args&&... args)
Ermöglicht das Currying von Funktionen mit variabler Anzahl von Argumenten. Danach können diese Funktionen mit dem Operator ()
ohne Argumente aufgerufen werden. Zum Beispiel:
auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42); c2();
Wenn der Parameter f
eine Funktion enthält, die bereits ausgeführt wurde, gibt er seine Kopie mit geändertem Anwendungstyp für die variable Anzahl von Argumenten mit den angewendeten Argumenten args
.
kari::curryN(F&& f, Args&&... args)
Ermöglicht das Curry-Funktionieren mit variabler Anzahl von Argumenten, indem eine genaue Anzahl N
von Argumenten angegeben wird, die angewendet werden sollen (mit Ausnahme der in args
). Zum Beispiel:
char buffer[256] = {'\0'}; auto c = kari::curryN<3>(std::snprintf, buffer, 256, "%d + %d = %d"); c(37, 5, 42); std::cout << buffer << std::endl;
Wenn der Parameter f
eine Funktion enthält, die bereits ausgeführt wurde, gibt er seine Kopie mit geändertem Anwendungstyp für N Argumente mit den angewendeten Argumenten args
.
kari::is_curried<F>, kari::is_curried_v<F>
Einige Hilfsstrukturen zur Überprüfung, ob eine Funktion bereits ausgeführt wurde. Zum Beispiel:
const auto l = [](int v1, int v2){ return v1 + v2; }; const auto c = curry(l);
kari::curry_t::operator()(As&&... as)
Der Bediener erlaubt eine vollständige oder teilweise Anwendung einer Curry-Funktion. Gibt die Curry-Funktion der verbleibenden Argumente der Anfangsfunktion F
oder den Wert dieser Funktion zurück, der durch ihre Anwendung auf den Rückstand alter und neuer Argumente as
. Zum Beispiel:
int foo(int v1, int v2, int v3, int v4) { return v1 + v2 + v3 + v4; } auto c0 = kari::curry(foo); auto c1 = c0(15, 20);
Wenn Sie mit curryV
oder curryN
eine Curry-Funktion ohne Argumente curryN
, wird sie aufgerufen, wenn genügend Argumente vorhanden sind. Andernfalls wird eine teilweise angewendete Funktion zurückgegeben. Zum Beispiel:
auto c0 = kari::curryV(std::printf, "%d + %d = %d"); auto c1 = c0(37, 5); auto c2 = c1(42);
Details der Implementierung
Wenn ich Ihnen Details zur Implementierung gebe, werde ich C ++ 17 verwenden, um den Text des Artikels kurz zu halten und unnötige Erklärungen und gestapelte SFINAE sowie Beispiele für Implementierungen zu vermeiden , die ich in C ++ hinzufügen musste 14 Standard. All dies finden Sie im Projekt- Repository , wo Sie es auch zu Ihren Favoriten hinzufügen können :)
make_curry(F&& f, std::tuple<Args...>&& args)
Eine Hilfsfunktion, die ein Funktionsobjekt curry_t
oder die angegebene Funktion f
auf die Argumente args
anwendet.
template < std::size_t N, typename F, typename... Args > constexpr auto make_curry(F&& f, std::tuple<Args...>&& args) { if constexpr ( N == 0 && std::is_invocable_v<F, Args...> ) { return std::apply(std::forward<F>(f), std::move(args)); } else { return curry_t< N, std::decay_t<F>, Args... >(std::forward<F>(f), std::move(args)); } } template < std::size_t N, typename F > constexpr decltype(auto) make_curry(F&& f) { return make_curry<N>(std::forward<F>(f), std::make_tuple()); }
Nun gibt es zwei interessante Dinge an dieser Funktion:
- Wir wenden es nur auf die Argumente an, wenn es für diese Argumente aufrufbar ist und der Anwendungszähler
N
auf Null steht - Wenn die Funktion nicht aufrufbar ist, betrachten wir diesen Aufruf als Teilanwendung und erstellen ein Funktionsobjekt
curry_t
das die Funktion und die Argumente enthält
struct curry_t
Das Funktionsobjekt, das den Rückstand an Argumenten speichern soll, und die Funktion, die wir beim Anwenden am Ende aufrufen werden. Dieses Objekt werden wir aufrufen und teilweise anwenden.
template < std::size_t N, typename F, typename... Args > struct curry_t { template < typename U > constexpr curry_t(U&& u, std::tuple<Args...>&& args) : f_(std::forward<U>(u)) , args_(std::move(args)) {} private: F f_; std::tuple<Args...> args_; };
Es gibt eine Reihe von Gründen, warum wir den Rückstand der Argumente args_
in std :: args_
speichern:
1) Situationen mit std :: ref werden automatisch behandelt, um Referenzen zu speichern, wenn dies erforderlich ist, standardmäßig basierend auf dem Wert
2) bequeme Anwendung einer Funktion gemäß ihren Argumenten ( std :: apply )
3) es ist fertig gemacht, so dass Sie es nicht von Grund auf neu schreiben müssen :)
Wir haben das aufgerufene Objekt und die Funktion f_
nach ihrem Wert gespeichert und sind vorsichtig, wenn Sie den Typ auswählen, wenn Sie einen erstellen (ich werde dieses Problem weiter unten erläutern) oder ihn verschieben oder kopieren, indem Sie die universelle Referenz in verwenden der Konstruktor.
Ein Vorlagenparameter N
dient als Anwendungszähler für verschiedene Funktionen.
curry_t::operator()(const As&...)
Und natürlich das, was alles zum Funktionieren bringt - der Operator, der das Funktionsobjekt aufruft.
template < std::size_t N, typename F, typename... Args > struct curry_t {
Der aufrufende Operator hat vier Funktionen überladen.
Eine Funktion ohne Parameter, mit der die Variadic-Funktion (erstellt von curryV
oder curryN
) curryN
. Hier dekrementieren wir den Anwendungszähler auf Null, um zu verdeutlichen, dass die Funktion zur Anwendung bereit ist, und geben dann alles, was dafür erforderlich ist, an make_curry
Funktion make_curry
.
Eine Funktion eines einzelnen Arguments, die den Anwendungszähler um 1 dekrementiert (wenn er nicht Null ist) und unser neues Argument a
in den Rückstand der Argumente args_
und all dies an make_curry
.
Eine variable Funktion, die eigentlich ein Trick für die teilweise Anwendung verschiedener Argumente ist. Es wendet sie rekursiv nacheinander an. Es gibt zwei Gründe, warum sie nicht alle auf einmal angewendet werden können:
- Der Anwendungszähler kann auf Null sinken, bevor keine Argumente mehr vorhanden sind
- Die Funktion
f_
kann früher aufgerufen werden und eine andere Curry-Funktion zurückgeben, sodass alle nächsten Argumente dafür bestimmt sind
Die letzte Funktion fungiert als Brücke zwischen dem Aufruf von curry_t
mit lvalue und dem Aufruf von Funktionen mit rvalue .
Die Tags von ref-qualifizierten Funktionen machen den gesamten Prozess fast magisch. Um es kurz zu machen, mit ihrer Hilfe erfahren wir, dass ein Objekt mit der rvalue- Referenz aufgerufen wurde, und wir können die Argumente einfach verschieben, anstatt sie in der make_curry
. Andernfalls müssten wir die Argumente kopieren, um diese Funktion weiterhin aufrufen zu können und sicherzustellen, dass die Argumente noch vorhanden sind.
Boni
Bevor ich zum Schluss komme , möchte ich Ihnen einige Beispiele für den syntaktischen Zucker zeigen, den sie in kari.hpp haben und der als Bonus qualifiziert werden kann.
Bedienerabschnitte
Die Programmierer, die bereits mit Haskell gearbeitet haben, sollten mit Operatorabschnitten vertraut sein, die eine kurze Beschreibung der angewendeten Operatoren ermöglichen. Zum Beispiel generiert die Struktur (*2)
eine Einzelargumentfunktion, die das Ergebnis der Multiplikation dieses Arguments mit 2 zurückgibt. Ich wollte also versuchen, so etwas in C ++ zu schreiben. Kaum gesagt als getan!
using namespace kari::underscore; std::vector<int> v{1,2,3,4,5}; std::accumulate(v.begin(), v.end(), 0, _+_);
Funktionszusammensetzung
Und natürlich wäre ich kein Vollidiot, wenn ich nicht versucht hätte, eine Funktionskomposition zu schreiben. Als Kompositionsoperator habe ich den operator *
als das Symbol gewählt, das dem Kompositionszeichen in der Mathematik am nächsten kommt. Ich habe es verwendet, um die resultierende Funktion auch für ein Argument anzuwenden. Also, das habe ich bekommen:
using namespace kari::underscore;
- Die Zusammensetzung der Funktionen
(*2)
und (+2)
wird auf 4
angewendet. (4 + 2) * 2 = 12
- Funktion
(*2)
wird auf 4
angewendet und dann wenden wir (+2)
auf das Ergebnis an. (4 * 2 + 2) = 10
Auf die gleiche Weise können Sie recht komplexe Kompositionen im punktfreien Stil erstellen , aber denken Sie daran, dass nur Haskell-Programmierer diese verstehen werden :)
Fazit
Ich denke, es war vorher ziemlich klar, dass es nicht nötig ist, diese Techniken in realen Projekten einzusetzen. Trotzdem muss ich das erwähnen. Mein Ziel war es schließlich, mich zu beweisen und den neuen C ++ - Standard zu überprüfen. Würde ich das tun können? Und würde C ++? Nun, ich denke, Sie haben gerade gesehen, dass wir beide das irgendwie getan haben. Und ich bin allen sehr dankbar, die das Ganze gelesen haben.