Kapitel 4. Funktionale Datenstrukturen
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Datenstrukturen sind ein grundlegendes Konzept in der Informatik. Zusammen mit Algorithmen gehören sie zu den Grundlagen, die ein Informatikstudent oder Programmierer beherrschen muss. Wie der Begriff funktionale Muster ist auch die funktionale Datenstruktur kein etabliertes Konzept mit einer einheitlichen Definition im Kanon des Codes.
In diesem Buch werde ich zwei Dinge als funktionale Datenstrukturen bezeichnen.
-
Strukturen, die in funktionalen Mustern verwendet werden, wie z.B.
Option
,Either
,Try
,List
. Dies sind Monaden. -
Gewöhnliche Datenstrukturen, die funktional implementiert sind, so dass sie ihren Zustand nicht verändern, wie z. B. eine verkettete Liste.
In diesem Kapitel befassen wir uns mit der ersten Art von funktionalen Datenstrukturen und gehen dann kurz auf einige Ideen rund um gewöhnliche Datenstrukturen ein, die auf funktionale Weise implementiert werden. Bevor wir uns bestimmte Datenstrukturen ansehen, möchte ich eine Idee über funktionale Datenstrukturen erwähnen, die in der Literatur diskutiert wurde. Da ist zunächst dieses Zitat von Alan Perlis:
Es ist besser, 100 Funktionen auf eine Datenstruktur wirken zu lassen als 10 Funktionen auf 10 Datenstrukturen.
Erfahrene funktionale Programmierer verwenden in der Regel eine kleine Anzahl von Datenstrukturen: verknüpfte Listen, Arrays und Hashtabellen sowie Strukturen wie Option
, Either
und Try
, um nur einige zu nennen. Diese werden wir als nächstes untersuchen. Ich glaube, die Idee hinter diesem Zitat ist, dass weniger Datenstrukturen zu mehr Einheitlichkeit und Einfachheit in der Codebasis führen. Schauen wir uns nun einige Datenstrukturen an, die besonders funktional sind. Wir beginnen mit Option
.
Die Datenstruktur der Option
Ich verwende das Wort Option
als sprachneutralen Begriff (nicht als Teil einer bestimmten Programmiersprache). Verschiedene Programmiersprachen haben eine Version dieser Datenstruktur und ich werde Beispiele für einige von ihnen geben, aber zuerst wollen wir die Idee näher erläutern. Unsere Diskussion sollte eigentlich mit dem null
Konstrukt beginnen. Null wird für einen Wert verwendet, der möglicherweise nicht existiert; ein optionaler Wert oder eine Situation, in der ein Wert nicht bekannt ist. Ein Problem mit null
ist, dass, wenn eine Variable ein Objekt enthält und der Wert dieser Variable null
ist, und eine Methode für dieses Objekt aufgerufen wird, das Programm eine Nullzeiger-Ausnahme auslöst. Ausnahmen unterbrechen die referenzielle Transparenz und sind daher bei funktionalen Programmierern verpönt. Tony Hoare, der Erfinder von null
, sagte, dies sei sein Milliarden-Dollar-Fehler:
Ich nenne ihn meinen Milliarden-Dollar-Fehler. Es war die Erfindung der Null-Referenz im Jahr 1965. Zu dieser Zeit entwarf ich das erste umfassende Typsystem für Referenzen in einer objektorientierten Sprache (ALGOL W). Mein Ziel ( ) war es, sicherzustellen, dass die Verwendung von Referenzen absolut sicher ist und vom Compiler automatisch überprüft wird. Aber ich konnte der Versuchung nicht widerstehen, eine Null-Referenz einzufügen, einfach weil sie so einfach zu implementieren war. Das hat zu unzähligen Fehlern, Schwachstellen und Systemabstürzen geführt, die in den letzten vierzig Jahren wahrscheinlich eine Milliarde Dollar an Schmerz und Schaden verursacht haben.1
Doch wie gehen wir ohne null
mit Daten um, die optional sind oder die vielleicht keinen Wert haben? Nun, eines der Hauptprobleme von null
ist, dass es keinen richtigen Typ hat. Was wäre, wenn wir einen Typ erstellen könnten, der den Fall darstellt, dass ein Wert fehlt oder optional ist? So etwas gibt es in vielen Sprachen. In den Sprachen, in denen es ihn nicht gibt, ist es nicht schwer, ihn zu erstellen. Es gibt einige Möglichkeiten, auf diesen Typ zu verweisen, aber die meisten davon beinhalten eine Version des Wortes Option
. Wir haben Optional
in Java.2 In Python gibt es etwas, das Option
in mancher Hinsicht ähnelt, aber nicht die volle Funktionalität von Option
hat: das None
Objekt. Es ist jedoch nicht schwer, Option
in Python zu konstruieren. Die Idee hinter Option
ist folgende: Angenommen, wir haben eine Methode, die ein id
Objekt annimmt und ein Customer
Objekt zurückgibt. Was tun wir, wenn es kein Customer
mit dem angegebenen id
gibt?
Warnung
Wir könnten eine Ausnahme auslösen, wenn die Customer
nicht gefunden wird, aber wie ich schon sagte, ist das nicht funktional, da es die referenzielle Transparenz unterbricht. Wenn eine Funktion eine Ausnahme auslöst, ist es nicht mehr wahr, dass man bei der gleichen Eingabe auch die gleiche Ausgabe erhält.
Auch wenn du Ausnahmen magst, ist dieses Beispiel nicht wirklich ein geeigneter Fall für eine Ausnahme. Eine Ausnahme bezieht sich auf etwas, das nicht passieren sollte, aber doch passiert. Es gibt also keinen guten Grund zu glauben, dass jedes id
System mit einer tatsächlichen Customer
übereinstimmt. Die Situation, in der das passiert, ist nicht ungewöhnlich. Man könnte sogar sagen, dass es eines der Ergebnisse ist, die wir erwarten sollten. Wie können wir also damit umgehen, ohne eine Ausnahme zu machen? Betrachten wir das Konstrukt Option
. Mit Option
meine ich eine allgemeine Datenstruktur und nicht ein Konstrukt in einer bestimmten Programmiersprache. Eine Option
besteht normalerweise aus zwei Teilen: Der eine ist ein Container, der einen Wert enthält. Das ist der Fall, wenn ein gültiger Wert irgendwie berechnet oder erhalten wurde. Der zweite Teil steht für den Fall, dass kein gültiger Wert vorhanden ist. Dies wäre eine Situation, in der eine Sprache ohne Option
Typ ein null
verwenden könnte. Sehen wir uns ein Beispiel in Scala an. Kehren wir zu dem Beispiel zurück, in dem wir einen Kunden mit dem Typ id
abrufen. In Scala können wir wie folgt vorgehen:
Scala
def
getCustomer
(
id
:
Int
):
Option
[
Customer
]
=
{
//code which retrieves customer from database.
}
Diese Funktion kann eines von zwei Dingen zurückgeben. Wenn die id
einem Kunden entspricht, gibt die Methode zurück:
Some
(
customer
)
Wenn kein Kunde zu diesem bestimmten id
gehört, gibt die Funktion den Wert zurück:
None
Du kannst dann die notwendigen Maßnahmen ergreifen, um mit dieser Situation umzugehen. Wenn Some(customer)
zurückgegeben wird, kannst du customer
auf verschiedene Weise aus dem Container Some
herausholen. Eine gängige Methode ist die Verwendung von Scalas Mustervergleichsfunktion. Hier ist ein Beispiel dafür:
getCustomer
(
17
)
match
{
case
Some
(
customer
)
=>
//do something with customer
case
None
=>
//Take action to handle this situation.
}
Ein anderer, vielleicht gängigerer Ansatz ist die Verwendung einer der Funktionen höherer Ordnung, wie map
, flatMap
oder filter
. Zum Beispiel:
val
customer
=
getCustomer
(
17
)
map
{
customer
=>
customer
.
firstName
}
Die Variable Kunde enthält in dem Fall, dass ein Wert ermittelt wurde, Some("Peter")
. Wenn kein Kunde gefunden wurde, wird None
zurückgegeben und der Programmierer kann damit umgehen.
Hinweis
Es ist wichtig zu verstehen, dass Some
und None
Typen sind und der Compiler daher helfen kann, Fehler zu finden, die mit ihnen verbunden sind, im Gegensatz zu null
, das zur Laufzeit (nicht zur Compilierzeit) auftritt.
Nehmen wir an, du hättest eine Liste von Option
Objekten, jedes ein Some(customer)
oder ein None
. Und nehmen wir an, du möchtest dies in eine Kundenliste ändern, bei der du die None
s ignorierst. Nehmen wir auch an, getCustomers
gibt eine Liste von Optionen von Customer
Objekten zurück. Das Customer
Objekt könnte ein case class
sein, das so aussieht:
case
class
Customer
(
id
:
Int
,
firstName
:
String
)
Die Liste der Optionen für Kunden sieht wie folgt aus:
val
customers
=
List
(
Some
(
Customer
(
1
,
"Bob"
)),
None
,
Some
(
Customer
(
33
,
"Lee"
)),
None
,
Some
(
Customer
(
5
,
"Rhonda"
)))
Dann könnten wir Folgendes tun:
customers
.
flatten
Dies würde zurückkehren:
List
(
Customer
(
1
,
"Bob"
),
Customer
(
33
,
"Lee"
),
Customer
(
5
,
"Rhonda"
))
Beachte, dass die None
bequemerweise weggelassen wurden.
Schauen wir uns nun einige Beispiele in Java an.
Java
Optional
<
User
>
optUser
=
findUserById
(
123
);
optUser
.
ifPresent
(
user
->
{
System
.
out
.
println
(
"User's name = "
+
user
.
getName
());})
}
In diesem Beispiel gibt findUserById
ein Optional
eines User
Objekts zurück, wenn es eines für das angegebene id
findet. Wenn nicht, wird der Code in den geschweiften Klammern nicht ausgeführt. Die ältere Methode, die es vorOptional
gab, wäre gewesen, dass findUserById
ein null
zurückgibt, wenn kein Benutzer gefunden wurde. Das Problem dabei ist, dass bei einem Methodenaufruf des User
Objekts, das eigentlich ein null
ist, ein NullPointerException
ausgelöst werden würde. Bei dem vorangegangenen Code, der Optional
verwendet, wird keine Ausnahme ausgelöst.
In Python gibt es keine Klasse Option
. Es gibt zwar das Objekt None
, aber das ist nur die Hälfte dessen, was eine Option ausmacht. Es ist aber auch für sich genommen nützlich. Du kannst Dinge wie diese tun:
Python
def
getUser
(
id
):
#get user from database
#if database call fails
return
None
Dann kannst du das tun, wenn du diese Funktion aufrufst:
if
getUser
(
789
)
is
not
None
:
#do something
Du wirst vielleicht sagen, dass das sehr nach der Überprüfung von null
aussieht, und ich würde dir zustimmen. Wenn du es wirklich willst, kannst du in Python eine Option
Klasse erstellen. Eine Möglichkeit wird hier gezeigt:
class
Option
:
def
get_or_else
(
self
,
default
):
return
self
.
value
if
isinstance
(
self
,
Some
)
else
default
class
Some
(
Option
):
def
__init__
(
self
,
value
):
self
.
value
=
value
class
Nothing
(
Option
):
pass
Ich habe dies Nothing
genannt, weil Python bereits ein None
Objekt hat. Das ist nicht besonders "pythonisch", aber es ist eine Möglichkeit, die Aufgabe zu erledigen.
Auch C# hat eine Version dieser Idee. Es ist zwar kein Option
, aber es ist ein Konstrukt, das bei Problemen mit null
hilft: der Nullable
Typ. Mit diesem Typ können wir explizit darstellen, dass eine Variable einen null
Wert enthalten kann. Für einen bestimmten Typ, zum Beispiel int
, können wir den Typ bilden:
C#
Nullable
<
int
>
Wie du siehst, ist dies ein allgemeiner Typ. Eine Kurzform für diese Formulierung ist:
int?
Wir können auch Code schreiben wie:
Nullable
<
int
>
n1
=
new
Nullable
<
int
>(
10
);
Nullable
<
int
>
n2
=
null
;
if
(
n1
.
HasValue
)
{
process
(
n1
.
Value
);
}
else
{
//n1 has a null value.
}
Dies ist eine Möglichkeit, null
s zu navigieren, ohne eine NullPointerException
zu riskieren.
Die Datenstruktur von Try
Das Option
Konstrukt ist zwar nützlich und eine Verbesserung gegenüber null
s, aber es gibt dir keinen Hinweis darauf, warum es keinen legitimen Wert gibt. Ausnahmen tun dies, aber Option
s nicht. Option
s sind in vielen Situationen eine gute Lösung, aber manchmal brauchst du Informationen darüber, was schief gelaufen ist. Dafür gibt es das Try
Konstrukt. So wie Option
Some
und None
hat, hat Try
Success
und Failure
. Failure
umhüllt die Ausnahme, die ausgelöst wurde. Obwohl wir in FP versuchen, Ausnahmen so weit wie möglich zu vermeiden, lassen sie sich manchmal nicht vermeiden. Try
ist eine Lösung für dieses Problem. Hier ist etwas Code.
Scala
def
divide
(
a
:
Float
,
b
:
Float
):
Try
[
Float
]
=
Try
(
a
/
b
)
Dann können wir Folgendes tun:
divide
(
3
,
0
)
match
{
case
Success
(
result
)
=>
//do something with result
case
Failure
(
ex
)
=>
println
(
ex
.
getMessage
)
}
Hier ist ein Beispiel für Try
in einer for
Verständigung:
def
toInt
(
s
:
String
):
Try
[
Int
]
=
Try
(
Integer
.
parseInt
(
s
.
trim
))
val
y
=
for
{
a
<-
toInt
(
"9"
)
b
<-
toInt
(
"3.5"
)
c
<-
toInt
(
"6"
)
}
yield
a
+
b
+
c
Beachte, wie gut der Fehlerfall gehandhabt wird. y
enthält entweder eine Success
, die die Summe von a
, b
und c
enthält, oder eine Failure
, die eine Ausnahme enthält.
In diesem Fall wird das Ergebnis lauten:
Failure
(
java
.
lang
.
NumberFormatException
:
For
input
string
:
"3.5"
)
Die Datenstruktur von Either
Der Typ Try
gibt dir zwar mehr Informationen über einen Fehler oder ein außergewöhnliches Ergebnis als Option
, aber du könntest dich fragen, ob es einen Typ gibt, der dir mehr Flexibilität bietet. Das Either
-Konstrukt löst dieses Problem auf folgende Weise: Ein Either
-Objekt kann entweder ein Right
oder ein Left
sein. Right
ist wie der Some
-Typ von Option
. Left
ist wie None
, nur dass es jeden Typ umhüllen kann. Betrachten wir ein Beispiel in Scala.
Scala
def
divide
(
a
:
Int
,
b
:
Int
):
Either
[
String
,
Int
]
=
{
if
(
b
==
0
)
Left
(
"Can't divide by zero."
)
else
Right
(
a
/
b
)
}
Dann kannst du etwa so vorgehen:
divide
(
a
,
b
)
match
{
case
Left
(
msg
)
=>
//do something with msg
case
Right
(
res
)
=>
//do something with res
}
Left
und Right
können beliebige Typen verpacken. Nehmen wir zum Beispiel an, du hättest zwei benutzerdefinierte Typen, User
und Error
:
Scala
case
class
User
(
id
:
Int
,
firstName
:
String
)
case
class
Error
(
id
:
Int
,
text
:
String
)
def
getUser
(
id
:
Int
):
Either
[
Error
,
User
]
=
{
//If I retrieve a user from api
Right
(
User
(
id
,
firstName
)
//else if there is an error
Left
(
id
,
text
)
}
Dann kann ich Folgendes tun:
getUser
(
4
)
match
{
case
Right
(
user
)
=>
println
(
user
.
firstName
)
case
Left
(
error
)
=>
println
(
error
.
text
)
}
Left
kann sogar eine Ausnahme verpacken, wenn du es aus irgendeinem Grund für notwendig oder sinnvoll hältst, eine Ausnahme zu verwenden, und du keine Try
verwenden willst. Du könntest zum Beispiel eine Bibliothek verwenden, die eine Ausnahme auslöst. Es ist immer möglich, die Ausnahme abzufangen und einen in Left
verpackten Fehlertyp zurückzugeben.
Wenn wir tiefer in die FP einsteigen, wird es nützlich und in einigen Fällen notwendig sein, funktionale Bibliotheken von Drittanbietern zu verwenden, die viele der besprochenen Strukturen enthalten. Du hast bereits Beispiele in Scala, Java, Python und C# gesehen. Diese Sprachen unterscheiden sich darin, wie viel FP sie ohne Bibliotheken ausführen können, aber alle brauchen Bibliothekenfür einige Dinge.3 Für Java gibt es mindestens zwei Bibliotheken, die funktionale Konstrukte ermöglichen. Die eine heißt Functional Java und die andere ist Vavr. Ich werde Beispiele aus Vavr geben. Ich werde immer angeben, wenn ein Code auf Vavr basiert. Schauen wir uns an, wie Either
in Java mit Vavr aussieht.
Java mit Vavr
private
static
Either
<
String
,
User
>
getUser
(
int
id
)
{
//code that sucessfully found a user
return
Either
.
right
(
user
);
}
else
{
return
Either
.
left
(
String
.
format
(
"No user found with id %s"
,
id
);
}
}
Wie wäre es mit einer funktionalen Bibliothek für Python? Eine hervorragende Bibliothek ist OSlash. Mit OSlash können wir Dinge in Python schreiben wie die folgende :
Python (mit OSlash)
def
get_user
(
id
):
#successful case yields a user object
return
Right
(
user
)
#error case returns an error object
return
Left
(
error
)
Dann können wir das tun:
if
isinstance
(
result
,
Right
):
#do something with successfull result
else
:
# result is an error
Funktionen höherer Ordnung
Funktionen, die diese funktionalen Datenstrukturen wie Option
oder Either
zurückgeben, eignen sich für Funktionen höherer Ordnung. Sehen wir uns zunächst einige einfache Beispiele dafür an.
Scala
case
class
User
(
id
,
firstName
)
val
users
:
List
[
Option
[
User
]]
=
List
(
Some
(
User
(
1
,
"Jack"
)),
None
,
Some
(
User
(
2
,
"Andrea"
)),
None
,
None
)
Die users
Liste könnte das Ergebnis einer Funktion sein, die eine API abfragt und die Optionen der Nutzer zurückbekommt. In den Fällen, in denen kein User
für ein bestimmtes id
gefunden wurde, gibt die Funktion ein None
zurück. Um nur die users
und nicht die None
s zu erhalten, können wir Folgendes tun:
users
.
flatten
Dies wird zurückgegeben:
List
(
User
(
1
,
"Jack"
),
User
(
2
,
"Andrea"
))
Oft hat man eine Datenpipeline von Werten, die man umwandelt, während man eine Reihe von Funktionen höherer Ordnung aufruft. Nehmen wir zum Beispiel an, wir haben eine Scala-Funktion, getUsers
, die eine Liste von Either[Error,User]
zurückgibt, wobei User
ein Feld namens email
hat. Nehmen wir an, dass wir alle Nutzer mit einem example.com-Konto herausfiltern wollen. Wir erwarten, dass das Ergebnis eine List[Either[Error,String]]
ist, wobei String
die E-Mail-Adresse darstellt. Ein imperativer Ansatz, der die List
in einer Schleife durchläuft, wäre ein bisschen unübersichtlich. Sehen wir uns an, wie ein funktionaler Ansatz, der Funktionen höherer Ordnung verwendet, den Code stark vereinfacht. Dabei geht es direkt um das Wesentliche, was vor sich geht. Schleifen gehören nicht zu diesem Kern. Schauen wir uns etwas Code an.
Scala
case
class
User
(
id
:
Int
,
:
String
)
case
class
Error
(
id
:
Int
,
text
:
String
)
def
getUsers
():
List
[
Either
[
Error
,
User
]]
=
List
(
Right
(
User
(
1
,
"jack@example.com"
)),
Left
(
Error
(
4
,
"user not found"
)),
Right
(
User
(
2
,
"andrea@example.com"
)))
Dann können wir das tun:
val
emails
=
getUsers
().
map
(
either
=>
either
.
map
(
u
=>
u
.
))
Jetzt enthalten die E-Mails Folgendes:
List
(
Right
(
jack
@example
.
com
),
Left
(
Error
(
4
,
user
not
found
)),
Right
(
andrea
@example
.
com
))
So kannst du die Hierarchie der Felder bis zu dem gewünschten Feld aufschlüsseln.
Monaden in für Comprehensions in Scala
Wie wir gesehen haben, sind Dinge wie List
, Option
, Try
, und Either
Monaden. Eine sehr nützliche Sache, die du mit Monaden in Scala machen kannst, sind for
comprehensions. Hier ist ein Beispiel:
Scala
case
class
Student
(
id
:
Int
,
:
String
)
case
class
FinalGrade
(
grade
:
Int
)
def
getStudent
(
id
:
Int
):
Option
[
Student
]
=
???
def
getFinalGrade
(
student
:
Student
):
Option
[
FinalGrade
]
Diese Funktion versucht, ein Benutzerobjekt zu finden, das der angegebenen ID entspricht. Wenn sie es findet, gibt sie zum Beispiel Some(User(493, "Alex"))
zurück. Wenn sie es nicht findet, gibt sie None
zurück. Wir können nun Folgendes tun:
for
{
student
<-
getStudent
(
999
)
//getStudent returns a monad
finalGrade
<-
getFinalGrade
(
student
)
//getFinalGrade returns a monad
}
yield
(
student
,
finalGrade
)
Dies würde ein Tupel zurückgeben, das aus einem Option
der student
und einem Option
der finalGrade
besteht. Wenn einer der Option
s None
wäre, würde der Wert des for
Verstehens None
sein und nicht das Tupel von Werten. Für das Verständnis bedeutet dies, dass perfekt funktionaler Code eher wie traditioneller imperativer Code aussieht. In vielen Fällen führt dies zu einem klareren Code. Es stellt sich heraus, dass eine for
comprehension eigentlich nur ein syntaktischer Zucker für eine Reihe von flatMap
und map
Aufrufen ist und wir wissen, dass jede Monade flatMap
und map
hat. Beginnen wir mit einer Liste von Option
s von students
:
Scala
case
class
Student
(
id
:
Int
,
:
String
)
val
students
=
List
(
Some
(
Student
(
1
,
"jack@example.com"
)),
None
,
Some
(
Student
(
2
,
"andrea@example.com"
)),
None
)
Nehmen wir zunächst an, du willst nur die students
aus den Optionen herausholen und die None
s ignorieren. Das kannst du mit einem einfachen Aufruf von flatten
erreichen:
students
.
flatten
// returns List(Student(1,"jack@example.com"),
Student
(
2
,
"andrea@example.com"
))
Nehmen wir an, du interessierst dich nur für die emails
und möchtest eine Liste von ihnen bekommen. Dann kannst du Folgendes tun:
students
.
flatten
.
map
(
student
=>
student
.
)
// This will return List("jack@example.com","andrea@example.com")
Wir haben also ein flatten
und dann ein map
gemacht. Manchmal willst du zuerst map
und dann flatten
machen. Das ist genau das, was flatMap
macht. Schauen wir uns ein Beispiel dafür an:
students
.
flatMap
(
optStudent
=>
opStudent
)
// returns List(Student(1,"jack@example.com"), Student(2,"andrea@example.com"))
In diesem Fall haben wir für die map
die Identitätsfunktion verwendet. Damit ist der Aufruf von flatMap
dasselbe wie der von flatten
. Wir haben nicht die volle Potenz von flatMap
verwendet. Sehen wir uns ein komplizierteres Beispiel an. Nehmen wir an, wir haben eine getStudent
Funktion, die ein id
annimmt und ein Option
eines Student
zurückgibt. Das ist eine gute Idee, denn manche id
s verweisen vielleicht auf gar kein Student
. Hier ist etwas Code.
def
getStudent
(
id
:
Int
):
Option
[
Student
]
=
{
id
match
{
case
1
=>
Some
(
Student
(
1
,
"jack@example.com"
))
case
2
=>
Some
(
Student
(
2
,
"andrea@example.com"
))
case
_
=>
None
}
}
Nehmen wir nun an, dass wir a von id
s haben und wir die vorherige Funktion auf der Liste irgendwie aufrufen wollen, aber die Funktion gibt Option
zurück. Wir können Folgendes tun:
List
(
1
,
2
,
3
).
map
(
id
=>
getStudent
(
id
))
Dies wird zurückgegeben:
List
(
Some
(
Student
(
1
,
jack
@example
.
com
)),
Some
(
Student
(
2
,
andrea
@example
.
com
)),
None
)
Das ist wahrscheinlich nicht das, was wir wollten. flatMap
zur Rettung.
List
(
1
,
2
,
3
).
flatMap
(
id
=>
getStudent
(
id
))
Jetzt wird das wiederkommen:
List
(
Student
(
1
,
jack
@example
.
com
),
Student
(
2
,
andrea
@example
.
com
))
Perfekt! flatMap
ist nützlich, wenn du eine Liste (oder eine beliebige Monade) hast und sie map
willst, aber die Funktion, mit der du sie map
willst, selbst eine Monade zurückgibt. Der flatten
Teil von flatMap
entfernt das Ergebnis aus seinem Container, d.h .seiner Monade.
Traditionelle Datenstrukturen
Jetzt werden wir uns die traditionellen Datenstrukturen ansehen, im Gegensatz zu den funktionalenDatenstrukturen.
Unveränderlichkeit und Geschichte
Wie bei allem, was funktional ist, ist die Unveränderlichkeit ein wichtiges Teil des Puzzles. Was ich hier Geschichte nenne, bezieht sich auf die Tatsache, dass wir, da wir nichts ändern, etwas kopieren müssen, das sich ändert (und seinen früheren Zustand beibehält). Ein Stapel kann zum Beispiel funktional sein, wenn er auf eine bestimmte Weise implementiert wird. Bei Operationen wie push
müsste eine Kopie des Stapels erstellt werden, anstatt ihn an Ort und Stelle zu verändern. Das erfordert im Allgemeinen eine Möglichkeit, schnelle Kopien zu erstellen. Hierfür gibt es verschiedene Algorithmen. Es gibt sogar Datenbanken, die diesem Paradigma folgen. Datomic zum Beispiel, eine Datenbank, die von Rich Hickey, dem Schöpfer der Sprache Clojure (die viele funktionale Fähigkeiten hat), entwickelt wurde, ist eine Datenbank, die niemals Daten überschreibt. Sie behält eine Kopie aller Daten und Änderungen. Das ist eine weitere Möglichkeit, wie sich das funktionale Paradigma durchsetzen kann.
Hinweis
Ob eine traditionelle Datenstruktur funktional ist oder nicht, hängt von der jeweiligen Implementierung der Datenstruktur ab.
Faulheit
Wir haben Faulheit schon einmal im Zusammenhang mit dem, was eine Sprache funktional macht, erwähnt (siehe "Lazy Evaluation"). Der Grundgedanke ist, dass eine Funktion oder ein Prozess faul ist, wenn er nur bei Bedarf ausgewertet wird. Dazu gehört auch, dass eine Funktion, die eine lange Berechnung durchführt, den Wert speichert oder memoisiert und dann das nächste Mal verwendet, wenn er benötigt wird (wenn möglich). Diese Fähigkeit ist nützlich, um effiziente funktionale Datenstrukturen zu erstellen. Haskell ist eine Lazy-Sprache und Scala hat zwar ein Lazy-Schlüsselwort, ist aber nicht standardmäßig lazy. In der Regel ist eine Sprache, die teilweise oder vollständig lazy ist, eine Sprache, die explizit dafür entwickelt wurde, zumindest teilweise funktional zu sein.
Hinweis
Die Faulheit erlaubt es uns, mit der Auswertung eines Ausdrucks zu warten, wenn und nur wenn wir ihn brauchen.
Fazit
Zusammenfassend kann man sagen, dass es zwei Arten von funktionalen Datenstrukturen gibt. Die erste ist eine Reihe von Konstrukten, die aus der Kategorientheorie stammen, in der Regel über Haskell, und die eine map
und flatMap
Funktion haben, was sie zu Monaden macht. Die zweite besteht aus traditionellen Datenstrukturen, die nach den bereits erwähnten Prinzipien implementiert wurden.
1 Hoare, Tony. "Null-Referenzen: Der Milliarden-Dollar-Fehler". Historically Bad Ideas. Vortrag gehalten auf der QCon, 2009. https://oreil.ly/sQSnH.
2 Wie viele funktionale Konstrukte erfordert auch dieses Java 8 oder höher.
Get Funktionale Programmierung lernen 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.