Kapitel 4. Intelligent arbeiten, nicht hartmit funktionalem Code
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Alles, was ich bisher behandelt habe, ist FP, so wie es das C#-Team von Microsoft vorgesehen hat. Du findest diese Funktionen zusammen mit Beispielen auf der Microsoft-Website. In diesem Kapitel möchte ich jedoch ein bisschen kreativer mit C# werden.
Ich weiß nicht, wie es dir geht, aber ich bin gerne faul, oder zumindest mag ich es nicht, meine Zeit mit langweiligem Boilerplate-Code zu verschwenden. Eines der vielen wunderbaren Dinge an FP ist seine Prägnanz, verglichen mit imperativem Code.
In diesem Kapitel zeige ich dir, wie du die funktionalen Möglichkeiten noch weiter ausreizen kannst, als es die Standardversion von C# erlaubt. Außerdem lernst du, wie du einige der neueren funktionalen Funktionen von C# in älteren Versionen der Sprache implementieren kannst, damit du hoffentlich viel schneller mit deiner Arbeit weitermachen kannst.
In diesem Kapitel werden einige Kategorien von funktionalen Konzepten untersucht:
Func
s in Aufzählungen-
Func
Delegates werden scheinbar nicht so oft verwendet, aber sie sind unglaublich leistungsstarke Funktionen von C#. Ich zeige dir ein paar Möglichkeiten, wie du sie nutzen kannst, um die Möglichkeiten von C# zu erweitern. In diesem Fall fügen wir sie zu Enumerables hinzu und bearbeiten sie mit LINQ-Ausdrücken. Func
s als Filter-
Du kannst auch
Func
Delegierte als Filter verwenden - etwas, das sich zwischen dich und den eigentlichen Wert setzt, den du erreichen willst. Wenn du diese Prinzipien anwendest, kannst du ein paar nette Dinge im Code schreiben. - Benutzerdefinierte Aufzählungszeichen
-
Ich habe bereits über die Schnittstelle
IEnumerable
gesprochen und wie cool sie ist. Aber wusstest du, dass du sie aufbrechen und dein eigenes, individuelles Verhalten implementieren kannst? Ich zeige dir, wie das geht.
All das und noch viele andere Konzepte!
Es ist Zeit, func-y zu werden
Die Func
Delegatentypen sind Funktionen, die als Variablen gespeichert werden. Du legst fest, welche Parameter sie annehmen und was sie zurückgeben, und rufst sie wie jede andere Funktion auf. Hier ist ein kurzes Beispiel:
private
readonly
Func
<
Person
,
DateTime
,
string
>
SayHello
=
(
Person
p
,
DateTime
today
)
=>
today
+
" : "
+
"Hello "
+
p
.
Name
;
Der letzte generische Typ in der Liste zwischen den beiden spitzen Klammern ist der Rückgabewert; alle vorherigen Typen sind die Parameter. Dieses Beispiel nimmt zwei String-Parameter entgegen und gibt einen String zurück.
Du wirst von nun an sehr viele Func
Delegierte sehen, also vergewissere dich bitte, dass du dich mit ihnen wohlfühlst, bevor du weiterliest.
Funktionen in Enumerables
Ich habe schon viele Beispiele für Func
als Parameter für Funktionen gesehen, aber ich bin mir nicht sicher, ob viele Entwickler wissen, dass man sie in eine Aufzählung packen und damit interessante Verhaltensweisen erzeugen kann.
Die erste ist die offensichtliche: Setze sie in ein Array, um dieselben Daten mehrmals zu bearbeiten:
private
IEnumerable
<
Func
<
Employee
,
string
>>
descriptors
=
new
[]
{
x
=>
"First Name = "
+
x
.
firstName
,
x
=>
"Last Name = "
+
x
.
lastName
,
x
=>
"MiddleNames = string.Join("
", x.MiddleNames)
}
public
string
DescribeEmployee
(
Employee
emp
)
=>
string
.
Join
(
Environment
.
NewLine
,
descriptors
.
Select
(
x
=>
x
(
emp
)));
Mit dieser Technik können wir eine einzige ursprüngliche Datenquelle (hier ein Employee
Objekt) verwenden und daraus mehrere Datensätze desselben Typs generieren. In diesem Fall aggregieren wir mit der eingebauten .NET-Methode string.Join
, um dem Endnutzer eine einzige, einheitliche Zeichenfolge zu präsentieren.
Dieser Ansatz hat einige Vorteile gegenüber einer einfachen StringBuilder
. Erstens kann das Array dynamisch zusammengesetzt werden. Wir könnten mehrere Regeln für jede Eigenschaft und deren Darstellung haben, die je nach benutzerdefinierter Logik aus einer Reihe lokaler Variablen ausgewählt werden können.
Zweitens handelt es sich um eine Aufzählungsdatei. Indem wir sie auf diese Weise definieren, nutzen wir eine Funktion von Aufzählungsdateien, die "Lazy Evaluation" genannt wird (eingeführt in Kapitel 2). Das Besondere an Enumerables ist, dass sie keine Arrays sind; sie sind nicht einmal Daten. Sie sind nur Zeiger auf etwas, das uns sagt, wie wir die Daten extrahieren können. Es kann gut sein - und das ist in der Regel auch der Fall -, dass die Quelle hinter der Aufzählung ein einfaches Array ist, aber nicht unbedingt. Eine Enumerable erfordert eine Funktion, die bei jedem Zugriff auf das nächste Element über eine foreach
Schleife ausgeführt wird. Enumerables wurden entwickelt, um sich erst im allerletzten Moment in tatsächliche Daten umzuwandeln - typischerweise beim Start einer foreach
Schleifeniteration. In den meisten Fällen spielt das keine Rolle, wenn die Aufzählung durch ein Array im Speicher gespeist wird, aber wenn eine teure Funktion oder eine Abfrage in einem externen System die Aufzählung antreibt, kann "Lazy Loading" unglaublich nützlich sein, um unnötige Arbeit zu vermeiden.
Die Elemente einer Aufzählung werden nacheinander ausgewertet und erst dann, wenn sie an der Reihe sind, von dem Prozess, der die Aufzählung durchführt, verwendet zu werden. Wenn wir zum Beispiel die Funktion LINQ Any
verwenden, um jedes Element in einer Aufzählung auszuwerten, Any
wird die Aufzählung beendet, sobald ein Element gefunden wird, das den angegebenen Kriterien entspricht.
Und schließlich ist diese Technik aus Sicht der Wartung einfacher zu handhaben. Das Hinzufügen einer neuen Zeile zum Endergebnis ist so einfach wie das Hinzufügen eines neuen Elements zum Array. Dieser Ansatz wirkt auch als Hemmschuh für künftige Programmierer, da er es ihnen erschwert, zu viel komplexe Logik dort unterzubringen, wo sie nicht hingehört.
Ein super-einfacher Validator
Stellen wir uns eine schnelle Validierungsfunktion vor, die in der Regel wie folgt aussieht:
public
bool
IsPasswordValid
(
string
password
)
{
if
(
password
.
Length
<
=
6
)
return
false
;
if
(
password
.
Length
>
20
)
return
false
;
if
(
!
password
.
Any
(
x
=
>
Char
.
IsLower
(
x
)
)
)
return
false
;
if
(
!
password
.
Any
(
x
=
>
Char
.
IsUpper
(
x
)
)
)
return
false
;
if
(
!
password
.
Any
(
x
=
>
Char
.
IsSymbol
(
x
)
)
)
return
false
;
if
(
password
.
Contains
(
"Justin"
,
StringComparison
.
OrdinalIgnoreCase
)
&
&
password
.
Contains
(
"Bieber"
,
StringComparison
.
OrdinalIgnoreCase
)
)
return
false
;
return
true
;
}
Nun, zunächst einmal ist das eine Menge Code für ein eigentlich recht einfaches Regelwerk. Der imperative Ansatz zwingt dich dazu, einen ganzen Haufen sich wiederholender Standardformulierungen zu schreiben. Wenn wir noch eine weitere Regel hinzufügen wollen, sind das potenziell vier neue Codezeilen, obwohl eigentlich nur eine für uns interessant ist.
Wenn es doch nur einen Weg gäbe, diesen Code in ein paar einfache Zeilen zusammenzufassen. Nun, da du so nett gefragt hast, hier ist er:
public
bool
IsPasswordValid
(
string
password
)
=>
new
Func
<
string
,
bool
>[]
{
x
=>
x
.
Length
>
6
,
x
=>
x
.
Length
<=
20
,
x
=>
x
.
Any
(
y
=>
Char
.
IsLower
(
y
)),
x
=>
x
.
Any
(
y
=>
Char
.
IsUpper
(
y
)),
x
=>
x
.
Any
(
y
=>
Char
.
IsSymbol
(
y
)),
x
=>
!
x
.
Contains
(
"Justin"
,
StringComparison
.
OrdinalIgnoreCase
)
&&
!
x
.
Contains
(
"Bieber"
,
StringComparison
.
OrdinalIgnoreCase
)
}.
All
(
f
=>
f
(
password
));
Jetzt ist es nicht mehr so lange hin, oder? Was haben wir hier gemacht? Wir haben alle Regeln in einem Array von Func
zusammengefasst, das aus string
eine bool
macht, d.h. eine einzelne Validierungsregel überprüft. Wir verwenden eine LINQ-Anweisung: .All()
. Der Zweck dieser Funktion ist es, den Lambda-Ausdruck, den wir ihr geben, gegen alle Elemente des Arrays auszuwerten, an das sie angehängt ist. Wenn ein einziges dieser Elemente false
zurückgibt, wird der Prozess vorzeitig beendet und false
wird von All()
zurückgegeben (wie bereits erwähnt, wird auf die nachfolgenden Werte nicht zugegriffen, so dass wir durch die faule Auswertung Zeit sparen, indem wir sie nicht auswerten). Wenn jedes einzelne der Elemente true
zurückgibt, gibt All()
auch true
zurück.
Wir haben das erste Codebeispiel praktisch neu erstellt, aber der Standardcode, den wir schreiben mussten -if
Anweisungen und frühe Rückgaben - ist jetzt in der Struktur enthalten.
Das hat auch den Vorteil, dass die Codestruktur wieder leicht zu pflegen ist. Wenn wir wollten, könnten wir sie sogar zu einer Erweiterungsmethode verallgemeinern. Ich mache das oft:
public
static
bool
IsValid
<
T
>(
this
T
@this
,
params
Func
<
T
,
bool
>[]
rules
)
=>
rules
.
All
(
x
=>
x
(
@this
));
Das reduziert die Größe des Passwort-Validators noch weiter und gibt uns eine praktische, generische Struktur, die wir an anderer Stelle verwenden können:
public
bool
IsPasswordValid
(
string
password
)
=>
password
.
IsValid
(
x
=>
x
.
Length
>
6
,
x
=>
x
.
Length
<=
20
,
x
=>
x
.
Any
(
y
=>
Char
.
IsLower
(
y
)),
x
=>
x
.
Any
(
y
=>
Char
.
IsUpper
(
y
)),
x
=>
x
.
Any
(
y
=>
Char
.
IsSymbol
(
y
)),
x
=>
!
x
.
Contains
(
"Justin"
,
StringComparison
.
OrdinalIgnoreCase
)
&&
!
x
.
Contains
(
"Bieber"
,
StringComparison
.
OrdinalIgnoreCase
)
)
An dieser Stelle hoffe ich, dass du es dir noch einmal überlegst, ob du jemals wieder etwas so langes und unhandliches wie das erste Validierungsbeispiel schreibst.
Ich denke, dass eine IsValid
Prüfung einfacher zu lesen und zu pflegen ist, aber wenn wir ein Stück Code wollen, das viel mehr mit dem ursprünglichen Codebeispiel übereinstimmt, können wir eine neue Erweiterungsmethode erstellen, indem wir Any()
anstelle von All()
verwenden:
public
static
bool
IsInvalid
<
T
>(
this
T
@this
,
params
Func
<
string
,
bool
>[]
rules
)
=>
Das bedeutet, dass die boolesche Logik jedes Array-Elements umgekehrt werden kann, wie es ursprünglich der Fall war:
public
bool
IsPasswordValid
(
string
password
)
=>
!
password
.
IsInvalid
(
x
=>
x
.
Length
<=
6
,
x
=>
x
.
Length
>
20
,
x
=>
!
x
.
Any
(
y
=>
Char
.
IsLower
(
y
)),
x
=>
!
x
.
Any
(
y
=>
Char
.
IsUpper
(
y
)),
x
=>
!
x
.
Any
(
y
=>
Char
.
IsSymbol
(
y
)),
x
=>
x
.
Contains
(
"Justin"
,
StringComparison
.
OrdinalIgnoreCase
)
&&
x
.
Contains
(
"Bieber"
,
StringComparison
.
OrdinalIgnoreCase
)
)
Wenn wir beide Funktionen, IsValid()
und IsInvalid()
, beibehalten wollen, weil jede ihren Platz in unserer Codebasis hat, lohnt es sich wahrscheinlich, etwas Programmieraufwand zu sparen und eine potenzielle Wartungsaufgabe in der Zukunft zu vermeiden, indem wir einfach eine Funktion in der anderen referenzieren:
public
static
bool
IsValid
<
T
>(
this
T
@this
,
params
Func
<
T
,
bool
>[]
rules
)
=>
rules
.
All
(
x
=>
x
(
@this
));
public
static
bool
IsInvalid
<
T
>(
this
T
@this
,
params
Func
<
T
,
bool
>[]
rules
)
=>
!
@this
.
IsValid
(
rules
);
Pattern Matching für alte Versionen von C#
Der Musterabgleich ist neben den Satztypen eines der besten Features von C# der letzten Jahre, aber er ist nur in den neuesten .NET-Versionen verfügbar. (In Kapitel 3 erfährst du mehr über das native Pattern Matching in C# 7 und höher).
Gibt es eine Möglichkeit, den Musterabgleich zu ermöglichen, ohne dass du auf eine neuere Version von C# upgraden musst? Die gibt es auf jeden Fall. Sie ist bei weitem nicht so elegant wie die native Syntax in C# 8, bietet aber einige der gleichen Vorteile.
In diesem Beispiel berechnen wir den Steuerbetrag, den jemand zahlen sollte, anhand einer stark vereinfachten Version der britischen Einkommenssteuerregeln. Beachte, dass diese wirklich viel einfacher sind als die echten. Ich möchte nicht, dass wir uns zu sehr in der Komplexität der Steuern verzetteln.
Die Regeln für die Anwendung sehen wie folgt aus:
-
Liegt das Jahreseinkommen unter oder bei £12.570, wird keine Steuer erhoben.
-
Liegt das Jahreseinkommen zwischen £12.571 und £50.270, musst du 20% Steuern zahlen.
-
Liegt das Jahreseinkommen zwischen £50.271 und £150.000, musst du 40% Steuern zahlen.
-
Wenn das Jahreseinkommen über 150.000 £ liegt, musst du 45 % Steuern zahlen.
Wenn wir das mit der Hand schreiben wollten (nicht funktional), würde es so aussehen:
decimal
ApplyTax
(
decimal
income
)
{
if
(
income
<=
12570
)
return
income
;
else
if
(
income
<=
50270
)
return
income
*
0.8
M
;
else
if
(
income
<=
150000
)
return
income
*
0.6
M
;
else
return
income
*
0.55
M
;
}
In C# 8 und höher wird dies mit switch
expressions auf ein paar Zeilen komprimiert. Solange wir mindestens mit C# 7 (.NET Framework 4.7) arbeiten, können wir diese Art von Mustervergleichen erstellen:
var
inputValue
=
25000
M
;
var
updatedValue
=
inputValue
.
Match
(
(
x
=>
x
<=
12570
,
x
=>
x
),
(
x
=>
x
<=
50270
,
x
=>
x
*
0.8
M
),
(
x
=>
x
<=
150000
,
x
=>
x
*
0.6
M
)
).
DefaultMatch
(
x
=>
x
*
0.55
M
);
Wir übergeben ein Array von Tupeln, das zwei Lambda-Ausdrücke enthält. Der erste bestimmt, ob die Eingabe mit dem aktuellen Muster übereinstimmt; der zweite ist die Umwandlung des Werts, die stattfindet, wenn das Muster übereinstimmt. Abschließend wird geprüft, ob das Standardmuster angewendet werden soll, weil keines der anderen Muster übereinstimmt.
Obwohl er nur einen Bruchteil der Länge des ursprünglichen Codebeispiels hat, enthält er die gleiche Funktionalität. Die übereinstimmenden Muster auf der linken Seite des Tupels sind einfach, aber sie können beliebig komplizierte Ausdrücke enthalten und sogar ganze Funktionen aufrufen, die detaillierte Kriterien enthalten.
Wie funktioniert das also? Dies ist eine extrem einfache Version, die die meisten der benötigten Funktionen bietet:
public
static
class
ExtensionMethods
{
public
static
TOutput
Match
<
TInput
,
TOutput
>(
this
TInput
@this
,
params
(
Func
<
TInput
,
bool
>
IsMatch
,
Func
<
TInput
,
TOutput
>
Transform
)[]
matches
)
{
var
match
=
matches
.
FirstOrDefault
(
x
=>
x
.
IsMatch
(
@this
));
var
returnValue
=
match
?.
Transform
(
@this
)
??
default
;
return
returnValue
;
}
}
Wir verwenden die LINQ-Methode FirstOrDefault()
, um zunächst durch die linken Funktionen zu iterieren, um eine zu finden, die true
zurückgibt (d.h. eine mit den richtigen Kriterien), und dann die rechte Umwandlung Func
aufzurufen, um den geänderten Wert zu erhalten.
Das ist gut, aber wenn keines der Muster übereinstimmt, sitzen wir in der Klemme. Höchstwahrscheinlich werden wir eine Null-Referenz-Ausnahme haben.
Um dies abzudecken, müssen wir eine Standardübereinstimmung erzwingen (das Äquivalent einer einfachen else
Anweisung oder der _ Musterübereinstimmung in switch
Ausdrücken). Die Antwort ist, dass die Funktion Match
ein Platzhalterobjekt zurückgibt, das entweder einen umgewandelten Wert aus den Match
Ausdrücken enthält oder den Default
Muster-Lambda-Ausdruck ausführt. Die verbesserte Version sieht wie folgt aus:
public
static
MatchValueOrDefault
<
TInput
,
TOutput
>
Match
<
TInput
,
TOutput
>(
this
TInput
@this
,
params
(
Func
<
TInput
,
bool
>,
Func
<
TInput
,
TOutput
>)[]
predicates
)
{
var
match
=
predicates
.
FirstOrDefault
(
x
=>
x
.
Item1
(
@this
));
var
returnValue
=
match
?.
Item2
(
@this
);
return
new
MatchValueOrDefault
<
TInput
,
TOutput
>(
returnValue
,
@this
);
}
public
class
MatchValueOrDefault
<
TInput
,
TOutput
>
{
private
readonly
TOutput
value
;
private
readonly
TInput
originalValue
;
public
MatchValueOrDefault
(
TOutput
value
,
TInput
originalValue
)
{
this
.
value
=
value
;
this
.
originalValue
=
originalValue
;
}
}
public
TOutput
DefaultMatch
(
Func
<
TInput
,
TOutput
>
defaultMatch
)
{
if
(
EqualityComparer
<
TOutput
>.
Default
.
Equals
(
default
,
this
.
value
))
{
return
defaultMatch
(
this
.
originalValue
);
}
else
{
return
this
.
value
;
}
}
Dieser Ansatz ist im Vergleich zu dem, was in den neuesten Versionen von C# erreicht werden kann, stark eingeschränkt. Es findet kein Objekttyp-Matching statt, und die Syntax ist nicht so elegant, aber sie ist immer noch brauchbar und könnte eine Menge Boilerplate einsparen und gute Codestandards fördern.
In noch älteren Versionen von C# und, die keine Tupel enthalten, können wir die Verwendung von KeyValuePair<T,T>
in Betracht ziehen, obwohl die Syntax alles andere als attraktiv ist. Was, du willst mir nicht aufs Wort glauben? Okay, dann mal los. Sag nicht, ich hätte dich nicht gewarnt...
Die Methode Extension()
selbst ist in etwa gleich und braucht nur eine kleine Änderung, um KeyValuePair
anstelle von Tupeln zu verwenden:
public
static
MatchValueOrDefault
<
TInput
,
TOutput
>
Match
<
TInput
,
TOutput
>(
this
TInput
@this
,
params
KeyValuePair
<
Func
<
TInput
,
bool
>,
Func
<
TInput
,
TOutput
>>[]
predicates
)
{
var
match
=
predicates
.
FirstOrDefault
(
x
=>
x
.
Key
(
@this
));
var
returnValue
=
match
.
Value
(
@this
);
return
new
MatchValueOrDefault
<
TInput
,
TOutput
>(
returnValue
,
@this
);
}
Und jetzt kommt der hässliche Teil. Die Syntax für die Erstellung von KeyValuePair
Objekten ist ziemlich furchtbar:
var
inputValue
=
25000
M
;
var
updatedValue
=
inputValue
.
Match
(
new
KeyValuePair
<
Func
<
decimal
,
bool
>,
Func
<
decimal
,
decimal
>>(
x
=>
x
<=
12570
,
x
=>
x
),
new
KeyValuePair
<
Func
<
decimal
,
bool
>,
Func
<
decimal
,
decimal
>>(
x
=>
x
<=
50270
,
x
=>
x
*
0.8
M
),
new
KeyValuePair
<
Func
<
decimal
,
bool
>,
Func
<
decimal
,
decimal
>>(
x
=>
x
<=
150000
,
x
=>
x
*
0.6
M
)
).
DefaultMatch
(
x
=>
x
*
0.55
M
);
Wir können also auch in C# 4 eine Form des Pattern Matching haben, aber ich bin mir nicht sicher, wie viel wir dadurch gewinnen. Das musst du vielleicht selbst entscheiden. Wenigstens habe ich dir den Weg gezeigt.
Wörterbücher nützlicher machen
Funktionen müssen nicht nur verwendet werden, um eine Form von Daten in eine andere zu verwandeln. Wir können sie auch als Filter verwenden, als zusätzliche Ebenen, die zwischen dem Entwickler und der ursprünglichen Informationsquelle oder Funktionalität liegen. In diesem Abschnitt wird gezeigt, wie funktionale Filter eingesetzt werden können, um die Nutzung von Wörterbüchern zu verbessern.
Eines meiner absoluten Lieblingsobjekte in C# sind Wörterbücher. Richtig eingesetzt, können sie einen Haufen hässlichen, mit Boilerplates überladenen Code mit ein paar einfachen, eleganten, Array-ähnlichen Lookups reduzieren. Außerdem sind sie effizient, um Daten zu finden, sobald sie erstellt sind.
Wörterbücher haben jedoch ein Problem, das es oft notwendig macht, einen Haufen Kauderwelsch hinzuzufügen, der den ganzen Grund, warum sie so schön zu benutzen sind, zunichte macht. Betrachte das folgende Codebeispiel:
var
doctorLookup
=
new
[]
{
(
1
,
"William Hartnell"
),
(
2
,
"Patrick Troughton"
),
(
3
,
"Jon Pertwee"
),
(
4
,
"Tom Baker"
)
}.
ToDictionary
(
x
=>
x
.
Item1
,
x
=>
x
.
Item2
);
var
fifthDoctorInfo
=
$
"The 5th Doctor was played by {doctorLookup[5]}"
;
Was hat es mit diesem Code auf sich? Er verstößt gegen eine Codefunktion von Wörterbüchern, die ich unerklärlich finde: wenn du versuchst, einen Eintrag zu suchen, der nicht existiert,1 wird eine Ausnahme ausgelöst, die behandelt werden muss!
Der einzige sichere Weg, dies zu handhaben, ist eine der verschiedenen Techniken zu verwenden, die in C# verfügbar sind, um die verfügbaren Schlüssel zu überprüfen, bevor der String kompiliert wird, wie zum Beispiel so:
var
doctorLookup
=
new
[
]
{
(
1
,
"William Hartnell"
)
,
(
2
,
"Patrick Troughton"
)
,
(
3
,
"Jon Pertwee"
)
,
(
4
,
"Tom Baker"
)
}
.
ToDictionary
(
x
=
>
x
.
Item1
,
x
=
>
x
.
Item2
)
;
var
fifthDoctorActor
=
doctorLookup
.
ContainsKey
(
5
)
?
doctorLookup
[
5
]
:
"An Unknown Actor"
;
var
fifthDoctorInfo
=
$
"The 5th Doctor was played by {fifthDoctorActor}"
;
Alternativ bieten etwas neuere Versionen von C# eine TryGetValue()
Funktion, die diesen Code ein wenig vereinfacht:
var
fifthDoctorActor
=
doctorLookup
.
TryGetValue
(
5
,
out
string
value
)
?
value
:
"An Unknown Actor"
;
Können wir also FP-Techniken verwenden, um unseren Boilerplate-Code zu reduzieren und uns alle nützlichen Funktionen von Wörterbüchern zu geben, aber ohne die schreckliche Tendenz zu explodieren? Darauf kannst du wetten!
Zuerst brauchen wir eine schnelle Erweiterungsmethode:
public
static
class
ExtensionMethods
{
public
static
Func
<
TKey
,
TValue
>
ToLookup
<
TKey
,
TValue
>(
this
IDictionary
<
TKey
,
TValue
>
@this
)
{
return
x
=>
@this
.
TryGetValue
(
x
,
out
TValue
?
value
)
?
value
:
default
;
}
public
static
Func
<
TKey
,
TValue
>
ToLookup
<
TKey
,
TValue
>(
this
IDictionary
<
TKey
,
TValue
>
@this
,
TValue
defaultVal
)
{
return
x
=>
@this
.
ContainsKey
(
x
)
?
@this
[
x
]
:
defaultVal
;
}
}
Das erkläre ich dir gleich, aber zuerst sehen wir uns an, wie wir die Erweiterungsmethoden verwenden:
var
doctorLookup
=
new
[]
{
(
1
,
"William Hartnell"
),
(
2
,
"Patrick Troughton"
),
(
3
,
"Jon Pertwee"
),
(
4
,
"Tom Baker"
)
}.
ToDictionary
(
x
=>
x
.
Item1
,
x
=>
x
.
Item2
)
.
ToLookup
(
"An Unknown Actor"
);
var
fifthDoctorInfo
=
$
"The 5th Doctor was played by {doctorLookup(5)}"
;
// output = "The 5th Doctor was played by An Unknown Actor"
Fällt dir der Unterschied auf? Wenn du genau hinsiehst, verwendet der Code jetzt Klammern anstelle von eckigen Array-/Wörterbuchklammern, um auf Werte aus dem Wörterbuch zuzugreifen. Das liegt daran, dass es sich technisch gesehen nicht mehr um ein Wörterbuch handelt! Es ist eine Funktion.
Wenn du dir die Erweiterungsmethoden ansiehst, geben sie Funktionen zurück, aber es sind Funktionen, die das ursprüngliche Dictionary
Objekt so lange im Geltungsbereich behalten, wie sie existieren. Im Grunde sind sie wie eine Filterschicht, die zwischen Dictionary
und dem Rest der Codebasis liegt. Die Funktionen entscheiden, ob die Verwendung von Dictionary
sicher ist.
Das bedeutet, dass wir Dictionary
verwenden können, aber die Ausnahme, die auftritt, wenn ein Schlüssel nicht gefunden wird, wird nicht mehr ausgelöst, und wir können entweder den Standardwert für den Typ (normalerweise null
) zurückgeben oder unseren eigenen Standardwert angeben. Einfach.
Der einzige Nachteil dieser Methode ist, dass sie nicht länger eine Dictionary
ist. Wir können sie nicht weiter verändern oder LINQ-Operationen mit ihr durchführen. In Situationen, in denen wir sicher sind, dass wir das nicht brauchen, können wir diese Methode verwenden.
Werte parsen
Eine weitere häufige Ursache für unübersichtlichen Code ist das Parsen von Werten aus string
in andere Formulare. So etwas könnten wir für das Parsen eines hypothetischen Einstellungsobjekts verwenden, falls wir in .NET Framework arbeiten und die Funktionen von appsettings.json und IOption<T>
nicht verfügbar sind:
public
Settings
GetSettings
()
{
var
settings
=
new
Settings
();
var
retriesString
=
ConfigurationManager
.
AppSettings
[
"NumberOfRetries"
];
var
retriesHasValue
=
int
.
TryParse
(
retriesString
,
out
var
retriesInt
);
if
(
retriesHasValue
)
settings
.
NumberOfRetries
=
retriesInt
;
else
settings
.
NumberOfRetries
=
5
;
var
pollingHrStr
=
ConfigurationManager
.
AppSettings
[
"HourToStartPollingAt"
];
var
pollingHourHasValue
=
int
.
TryParse
(
pollingHrStr
,
out
var
pollingHourInt
);
if
(
pollingHourHasValue
)
settings
.
HourToStartPollingAt
=
pollingHourInt
;
else
settings
.
HourToStartPollingAt
=
0
;
var
alertEmailStr
=
ConfigurationManager
.
AppSettings
[
"AlertEmailAddress"
];
if
(
string
.
IsNullOrWhiteSpace
(
alertEmailStr
))
settings
.
AlertEmailAddress
=
"test@thecompany.net"
;
else
settings
.
AlertEmailAddress
=
aea
.
ToString
();
var
serverNameString
=
ConfigurationManager
.
AppSettings
[
"ServerName"
];
if
(
string
.
IsNullOrWhiteSpace
(
serverNameString
))
settings
.
ServerName
=
"TestServer"
;
else
settings
.
ServerName
=
sn
.
ToString
();
return
settings
;
}
Das ist eine Menge Code, um etwas Einfaches zu tun, stimmt's? Eine Menge Kauderwelsch macht den Sinn des Codes nur für diejenigen sichtbar, die mit dieser Art von Vorgängen vertraut sind. Außerdem würden für jede neue Einstellung fünf oder sechs Zeilen neuer Code benötigt, wenn man sie hinzufügen würde. Das ist eine ziemliche Verschwendung.
Stattdessen können wir die Dinge etwas funktionaler angehen und die Struktur irgendwo verstecken, so dass nur die Absicht des Codes für uns sichtbar ist.
Wie immer gibt es auch hier eine Erweiterungsmethode, um das Geschäft zu erledigen:
public
static
class
ExtensionMethods
{
public
static
int
ToIntOrDefault
(
this
object
@this
,
int
defaultVal
=
0
)
=>
int
.
TryParse
(
@this
?.
ToString
()
??
string
.
Empty
,
out
var
parsedValue
)
?
parsedValue
:
defaultVal
;
public
static
string
ToStringOrDefault
(
this
object
@this
,
string
defaultVal
=
""
)
=>
string
.
IsNullOrWhiteSpace
(
@this
?.
ToString
()
??
string
.
Empty
)
?
defaultVal
:
@this
.
ToString
();
}
Damit entfällt der sich wiederholende Code aus dem ersten Beispiel und du kannst zu einem besser lesbaren, ergebnisorientierten Codebeispiel wie diesem übergehen:
public
Settings
GetSettings
()
=>
new
Settings
{
NumberOfRetries
=
ConfigurationManager
.
AppSettings
[
"NumberOfRetries"
]
.
ToIntOrDefault
(
5
),
HourToStartPollingAt
=
ConfigurationManager
.
AppSettings
[
"HourToStartPollingAt"
]
.
ToIntOrDefault
(
0
),
AlertEmailAddress
=
ConfigurationManager
.
AppSettings
[
"AlertEmailAddress"
]
.
ToStringOrDefault
(
"test@thecompany.net"
),
ServerName
=
ConfigurationManager
.
AppSettings
[
"ServerName"
]
.
ToStringOrDefault
(
"TestServer"
),
};
Jetzt ist es einfach, auf einen Blick zu sehen, was der Code macht, was die Standardwerte sind und wie wir mit einer einzigen Codezeile weitere Einstellungen hinzufügen können. Für alle anderen Einstellungswerte außer int
und string
müsste eine zusätzliche Erweiterungsmethode erstellt werden, aber das ist kein großes Problem.
Benutzerdefinierte Aufzählungen
Die meisten von uns haben beim Programmieren wahrscheinlich schon Enumerables verwendet, aber wusstest du, dass es unter der Oberfläche eine Engine gibt, auf die wir zugreifen und die wir nutzen können, um alle Arten von interessanten benutzerdefinierten Verhaltensweisen zu erstellen? Mit einem benutzerdefinierten Iterator können wir die Anzahl der Codezeilen, die für komplizierteres Verhalten benötigt werden, drastisch reduzieren, wenn wir Daten in Schleifen durchlaufen.
Zuerst müssen wir jedoch verstehen, wie eine Aufzählung unter der Oberfläche funktioniert. Unter der Oberfläche der Aufzählung befindet sich eine Klasse, der Motor, der die Aufzählung antreibt, und diese Klasse ermöglicht es uns, foreach
zu verwenden, um durch die Werte zu laufen. Sie wird Enumerator-Klasse genannt.
Der Enumerator hat zwei Funktionen:
Current
-
Damit wird das aktuelle Element aus der Aufzählung geholt. Diese Funktion kann so oft aufgerufen werden, wie wir wollen, solange wir nicht versuchen, zum nächsten Element zu wechseln. Wenn wir versuchen, den Wert
Current
abzurufen, bevor wirMoveNext()
aufgerufen haben, wird eine Ausnahme ausgelöst. MoveNext()
-
Geht vom aktuellen Element aus und versucht herauszufinden, ob es ein weiteres gibt, das ausgewählt werden kann. Gibt
true
zurück, wenn ein weiterer Wert gefunden wird, oderfalse
, wenn wir das Ende der Aufzählung erreicht haben oder es überhaupt keine Elemente gab. WennMoveNext()
zum ersten Mal aufgerufen wird, zeigt der Enumerator auf das erste Element in der Enumerable.
Angrenzende Elemente abfragen
Beginnen wir mit einem relativ einfachen Beispiel. Stell dir vor, wir wollen eine Aufzählung ganzer Zahlen durchgehen, um zu sehen, ob sie aufeinanderfolgende Zahlen enthält. Eine imperative Lösung würde wahrscheinlich wie folgt aussehen:
public
IEnumerable
<
int
>
GenerateRandomNumbers
()
{
var
rnd
=
new
Random
();
var
returnValue
=
new
List
<
int
>();
for
(
var
i
=
0
;
i
<
100
;
i
++)
{
returnValue
.
Add
(
rnd
.
Next
(
1
,
100
));
}
return
returnValue
;
}
public
bool
ContainsConsecutiveNumbers
(
IEnumerable
<
int
>
data
)
{
// OK, you caught me out: OrderBy isn't strictly imperative, but
// there's no way I'm going to write out a sorting algorithm out
// here just to prove a point!
var
sortedData
=
data
.
OrderBy
(
x
=>
x
).
ToArray
();
for
(
var
i
=
0
;
i
<
sortedData
.
Length
-
1
;
i
++)
{
if
((
sortedData
[
i
]
+
1
)
==
sortedData
[
i
+
1
])
return
true
;
}
return
false
;
}
var
result
=
ContainsConsecutiveNumbers
(
GenerateRandomNumbers
());
Console
.
WriteLine
(
result
);
Um diesen Code funktional zu machen, brauchen wir, wie so oft, eine Erweiterungsmethode. Diese würde das Enumerable nehmen, seinen Enumerator extrahieren und das angepasste Verhalten steuern.
Um die Verwendung einer Schleife im imperativen Stil zu vermeiden, verwenden wir hier die Rekursion. Rekursion (eingeführt in den Kapiteln 1 und 2) ist eine Möglichkeit, eine unendliche Schleife zu implementieren, indem eine Funktion sich selbst wiederholt aufruft.2
Auf das Konzept der Rekursion werde ich in Kapitel 9 zurückkommen. Für den Moment wollen wir die einfache Standardversion der Rekursion verwenden:
public
static
bool
Any
<
T
>(
this
IEnumerable
<
T
>
@this
,
Func
<
T
,
T
,
bool
>
evaluator
)
{
using
var
enumerator
=
@this
.
GetEnumerator
();
var
hasElements
=
enumerator
.
MoveNext
();
return
hasElements
&&
Any
(
enumerator
,
evaluator
,
enumerator
.
Current
);
}
private
static
bool
Any
<
T
>(
IEnumerator
<
T
>
enumerator
,
Func
<
T
,
T
,
bool
>
evaluator
,
T
previousElement
)
{
var
moreItems
=
enumerator
.
MoveNext
();
return
moreItems
&&
(
evaluator
(
previousElement
,
enumerator
.
Current
)
?
true
:
Any
(
enumerator
,
evaluator
,
enumerator
.
Current
));
}
Also, was passiert hier? Dieser Ansatz ist in gewisser Weise wie Jonglieren. Wir beginnen damit, den Enumerator zu extrahieren und gehen zum ersten Element.
In der privaten Funktion akzeptieren wir den Enumerator (der jetzt auf das erste Element verweist), die "Sind wir fertig"-Auswertungsfunktion und eine Kopie desselben ersten Elements.
Dann gehen wir sofort zum nächsten Punkt über und führen die Auswertefunktion aus, wobei wir den ersten Punkt und den neuen Current
übergeben, damit sie verglichen werden können.
An diesem Punkt stellen wir entweder fest, dass wir keine Items mehr haben, oder der Evaluator gibt true
zurück. In diesem Fall können wir die Iteration beenden. Wenn MoveNext()
true
zurückgibt, prüfen wir, ob previousValue
und Current
unseren Anforderungen entsprechen (wie in evaluator
angegeben). Wenn ja, beenden wir und geben true
zurück; andernfalls machen wir einen rekursiven Aufruf, um den Rest der Werte zu überprüfen.
Dies ist die aktualisierte Version des Codes, um fortlaufende Zahlen zu finden:
public
IEnumerable
<
int
>
GenerateRandomNumbers
()
{
var
rnd
=
new
Random
();
var
returnValue
=
Enumerable
.
Repeat
(
0
,
100
)
.
Select
(
x
=>
rnd
.
Next
(
1
,
100
));
return
returnValue
;
}
public
bool
ContainsConsecutiveNumbers
(
IEnumerable
<
int
>
data
)
{
var
sortedData
=
data
.
OrderBy
(
x
=>
x
).
ToArray
();
var
result
=
sortedData
.
Any
((
prev
,
curr
)
=>
cur
==
prev
+
1
);
return
result
;
}
Es wäre auch einfach, eine All()
Methode zu erstellen, die auf der gleichen Logik basiert,etwa so:
public
static
bool
All
<
T
>(
this
IEnumerator
<
T
>
enumerator
,
Func
<
T
,
T
,
bool
>
evaluator
,
T
previousElement
)
{
var
moreItems
=
enumerator
.
MoveNext
();
return
moreItems
?
evaluator
(
previousElement
,
enumerator
.
Current
)
?
All
(
enumerator
,
evaluator
,
enumerator
.
Current
)
:
false
:
true
;
}
public
static
bool
All
<
T
>(
this
IEnumerable
<
T
>
@this
,
Func
<
T
,
T
,
bool
>
evaluator
)
{
using
var
enumerator
=
@this
.
GetEnumerator
();
var
hasElements
=
enumerator
.
MoveNext
();
return
hasElements
?
All
(
enumerator
,
evaluator
,
enumerator
.
Current
)
:
true
;
}
Die einzigen Unterschiede zwischen All()
und Any()
sind die Bedingungen, unter denen entschieden wird, ob die Schleife fortgesetzt werden soll und ob du vorzeitig zurückkehren musst. Bei All()
geht es darum, jedes Wertepaar zu prüfen und nur dann vorzeitig aus der Schleife zurückzukehren, wenn einer der Werte die Kriterien nicht erfüllt.
Iterieren, bis eine Bedingung erfüllt ist
Die in diesem Abschnitt beschriebene Technik ist im Grunde ein Ersatz für eine while
Schleife, also gibt es eine weitere Anweisung, die wir nicht unbedingt brauchen.
Für dieses Beispiel stellen wir uns vor, wie das Zugsystem für ein textbasiertes Abenteuerspiel aussehen könnte. Für die jüngeren Leserinnen und Leser: Das war früher so, als es noch keine Grafiken gab. Du musstest aufschreiben, was du tun wolltest, und das Spiel schrieb, was passierte - ähnlich wie ein Buch, nur dass du selbst schriebst, was passierte.
Hinweis
Schau dir das epische Abenteuerspiel Zork an, wenn du das mit eigenen Augen sehen willst. Versuche, nicht von einem Grue gefressen zu werden!
Die Grundstruktur eines dieser Spiele war ungefähr so:
-
Schreibe eine Beschreibung des aktuellen Standorts.
-
Nehme Benutzereingaben entgegen.
-
Führe den angeforderten Befehl aus.
Hier siehst du, wie imperativer Code mit dieser Situation umgehen könnte:
var
gameState
=
new
State
{
IsAlive
=
true
,
HitPoints
=
100
};
while
(
gameState
.
IsAlive
)
{
var
message
=
this
.
ComposeMessageToUser
(
gameState
);
var
userInput
=
this
.
InteractWithUser
(
message
);
this
.
UpdateState
(
gameState
,
userInput
);
if
(
gameState
.
HitPoints
<=
0
)
gameState
.
IsAlive
=
false
;
}
Im Prinzip wollen wir eine Funktion im Stil von LINQ Aggregate()
, aber eine, die nicht in einer Schleife alle Elemente eines Arrays durchläuft und dann endet. Stattdessen soll die Funktion so lange in einer Schleife laufen, bis unsere Endbedingung erfüllt ist (der Spieler ist tot). Ich vereinfache hier ein wenig (natürlich könnte unser Spieler in einem richtigen Spiel auch gewinnen ). Aber mein Beispielspiel ist wie das Leben, und das Leben ist nicht fair!
Die Erweiterungsmethode ist ein weiterer Punkt, der von optimierten Aufrufen mit Tail-Rekursion profitieren würde, und ich werde in Kapitel 9 Optionen dafür vorstellen. Für den Moment werden wir jedoch nur eine einfache Rekursion verwenden (was zu einem Problem werden kann, wenn das Spiel viele Runden hat), um nicht zu viele Ideen zu früh einzuführen:
public
static
class
ExtensionMethods
{
public
static
T
AggregateUntil
<
T
>(
this
T
@this
,
Func
<
T
,
bool
>
endCondition
,
Func
<
T
,
T
>
update
)
=>
endCondition
(
@this
)
?
@this
:
AggregateUntil
(
update
(
@this
),
endCondition
,
update
);
}
Auf diese Weise können wir die while
Schleife ganz abschaffen und die gesamte Abbiegefolge in eine einzige Funktion umwandeln, etwa so:
var
gameState
=
new
State
{
IsAlive
=
true
,
HitPoints
=
100
};
var
endState
=
gameState
.
AggregateUntil
(
x
=>
x
.
HitPoints
<=
0
,
x
=>
{
var
message
=
this
.
ComposeMessageToUser
(
x
);
var
userInput
=
this
.
InteractWithUser
(
message
);
return
this
.
UpdateState
(
x
,
userInput
);
});
Das ist nicht perfekt, aber es funktioniert jetzt. Es gibt weitaus bessere Möglichkeiten, die verschiedenen Schritte zur Aktualisierung des Spielzustands zu handhaben, und auch die Frage, wie man die Benutzerinteraktion auf funktionale Weise handhabt, bleibt bestehen. In Kapitel 13 werden diese Themen behandelt.
Zusammenfassung
In diesem Kapitel haben wir uns angeschaut, wie man Func
Delegates, Enumerables und Erweiterungsmethoden nutzen kann, um C# zu erweitern, damit es einfacher wird, funktionalen Code zu schreiben und einige bestehende Einschränkungen der Sprache zu umgehen. Ich bin mir sicher, dass ich mit diesen Techniken nur an der Oberfläche kratze und dass es noch viele weitere gibt, die entdeckt und genutzt werden wollen.
Das nächste Kapitel befasst sich mit Funktionen höherer Ordnung und einigen Strukturen, mit denen man sie nutzen kann, um noch mehr nützliche Funktionen zu schaffen.
Get Funktionale Programmierung mit C# now with the O’Reilly learning platform.
O’Reilly members experience books, live events, courses curated by job role, and more from O’Reilly and nearly 200 top publishers.