Regel 4. Zur Verallgemeinerung braucht man drei Beispiele
Diese Arbeit wurde mithilfe von KI übersetzt. Wir freuen uns über dein Feedback und deine Kommentare: translation-feedback@oreilly.com
Als neue Programmierer/innen wird uns allen beigebracht, dass allgemeine Lösungen den spezifischen vorzuziehen sind. Es ist besser, eine Funktion zu schreiben, die zwei Probleme löst, als für jedes Problem eine eigene Funktion zu schreiben.
Es ist unwahrscheinlich, dass du diesen Code schreibst:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
Color
::
Red
)
return
sign
;
return
nullptr
;
}
Wenn es einfach wäre, diesen Code zu schreiben:
Sign
*
findSignByColor
(
const
vector
<
Sign
*>
&
signs
,
Color
color
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
color
)
return
sign
;
return
nullptr
;
}
Es ist ganz natürlich, in Begriffen der Verallgemeinerung zu denken, besonders bei so einem einfachen Beispiel. Wenn du alle roten Schilder auf der Welt finden musst, ist dein natürlicher Instinkt als Programmierer, den Code so zu schreiben, dass er Schilder einer beliebigen Farbe findet, und dann Rot als diese Farbe einzugeben. Die Natur verabscheut ein Vakuum; Programmierer verabscheuen Code, der nur ein Problem löst.
Es lohnt sich, darüber nachzudenken, warum sich das so natürlich anfühlt. Der Instinkt, findSignByColor
anstelle von findRedSign
zu schreiben, beruht auf einer Vorhersage. Wenn du nach einem roten Zeichen suchst, kannst du getrost vorhersagen, dass du irgendwann nach einem blauen Zeichen suchen wirst, und den Code auch für diesen Fall schreiben.
Warum also dort aufhören? Warum nicht eine noch allgemeinere Lösung zum Finden von Zeichen schreiben?
Du könntest eine allgemeinere Schnittstelle erstellen, mit der du jeden Aspekt des Schildes abfragen kannst - Farbe, Größe, Position, Text -, so dass die Suche nach einem Schild nach Farbe nur ein spezieller Unterfall ist. Dazu könntest du eine Struktur erstellen, die die zulässigen Werte für jeden Aspekt eines Schildes definiert:
bool
matchColors
(
const
vector
<
Color
>
&
colors
,
Color
colorMatch
)
{
if
(
colors
.
empty
())
return
true
;
for
(
Color
color
:
colors
)
if
(
color
==
colorMatch
)
return
true
;
return
false
;
}
bool
matchLocation
(
Location
location
,
float
distanceMax
,
Location
locationMatch
)
{
float
distance
=
getDistance
(
location
,
locationMatch
);
return
distance
<
distanceMax
;
}
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_location
(),
m_distance
(
FLT_MAX
),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchLocation
(
m_location
,
m_distance
,
sign
->
location
())
&&
regex_match
(
sign
->
text
(),
m_textExpression
);
}
vector
<
Color
>
m_colors
;
Location
m_location
;
float
m_distance
;
regex
m_textExpression
;
};
Die Gestaltung der Abfrageparameter erfordert einige Ermessensentscheidungen, da jeder Aspekt ein anderes Abfragemodell erfordert. In diesem Beispiel habe ich folgende Überlegungen angestellt:
-
Anstatt eine einzelne Farbe anzugeben, kannst du auch eine Liste mit akzeptablen Farben erstellen. Eine leere Liste gibt an, dass jede Farbe akzeptiert wird.
-
Intern speichert
Location
Breiten- und Längengrade als Fließkommawerte, sodass es nicht sinnvoll ist, nach einer genauen Übereinstimmung zu suchen. Stattdessen solltest du eine maximale Entfernung von einem bestimmten Ort angeben. -
Du könntest einen regulären Ausdruck verwenden, um den Text oder einen Teil des Textes des Zeichens abzugleichen, was viele offensichtliche Fälle abdecken würde.
Der eigentliche Code, um ein passendes Zeichen zu finden, ist einfach:
Sign
*
findSign
(
const
SignQuery
&
query
,
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
query
.
matchSign
(
sign
))
return
sign
;
return
nullptr
;
}
Ein rotes Zeichen zu finden, ist mit diesem Modell immer noch ziemlich einfach: Erstelle ein SignQuery
, gib Rot als einzige akzeptable Farbe an und rufe dann findSign
auf:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
SignQuery
query
;
query
.
m_colors
=
{
Color
::
Red
};
return
findSign
(
query
,
signs
);
}
Erinnere dich daran, dass das Design von SignQuery
auf einem Beispiel basiert: der Suche nach einem einzelnen roten Zeichen. Der Rest ist reine Spekulation. Zu diesem Zeitpunkt gibt es keine weiteren Beispiele, auf die du aufbauen kannst, also sagst du nur voraus, welche anderen Arten von Zeichen du finden musst.
Und genau das ist das Problem - deine Vorhersagen werden wahrscheinlich falsch sein. Wenn du Glück hast, liegen sie nur ein bisschen daneben... aber wahrscheinlich hast du kein Glück.
YAGNI
Die meisten Natürlich wirst du Fälle vorhersehen und lösen, die in der Praxis nie vorkommen. Die ersten Anwendungsfälle zum Finden von Schildern sehen vielleicht so aus:
-
Finde ein rotes Schild.
-
Finde ein Schild in der Nähe der Ecke Main Street und Barr Street.
-
Finde ein rotes Schild in der Nähe der 212 South Water Street.
-
Finde ein grünes Schild.
-
Finde ein rotes Schild in der Nähe der 902 Mill Street.
Du kannst alle diese Fälle mit SignQuery
und findSign
lösen. In dieser Hinsicht macht der Code also eine gute Figur bei der Vorhersage der Anwendungsfälle. Aber ich sehe keinen Fall, in dem du mehrere Schilderfarben akzeptierst, und keiner der Anwendungsfälle schaut sich den Text des Schildes an. Alle tatsächlichen Anwendungsfälle suchen höchstens nach einer einzigen Farbe und einige beschränken sich auf einen Ort. Der SignQuery
Code löst Fälle, die in der Praxis nicht vorkommen.
Dieses Muster ist so weit verbreitet, dass die Extreme Programming-Philosophie einen Namen dafür hat: YAGNI, oder "You Ain't Gonna Need It". Die Arbeit, die du gemacht hast, um eine Liste akzeptabler Farben zu definieren, anstatt nur eine einzige Farbe in deinem bekannten Anwendungsfall? Vergeudete Zeit und Mühe. Die Experimente, die du mit der C++ Klasse für reguläre Ausdrücke gemacht hast, um herauszufinden, wie du vollständige von teilweisen Übereinstimmungen unterscheiden kannst? Das ist Zeit, die du nicht zurückbekommst.
Außerdem ist die zusätzliche Komplexität von SignQuery
für jeden, der sie benutzt, mit Kosten verbunden. Es ist ziemlich offensichtlich, wie man die Funktion findSignByColor
verwendet, aber findSign
erfordert etwas mehr Nachforschung. Immerhin sind hier drei verschiedene Abfragemodelle enthalten!
Reicht eine Teilübereinstimmung des regulären Ausdrucks aus, oder muss der Ausdruck auf den gesamten Text des Zeichens passen? Es ist nicht klar, wie die drei Bedingungen zusammenhängen - ist das ein "und" oder ein "oder"? Wenn du den Code liest, ist klar, dass ein Zeichen nur dann auf die Abfrage passt, wenn alle Bedingungen zutreffen, aber dazu musst du den Code lesen. Das führt zu einer neuen Verwirrung - welche SignQuery
Felder sind erforderlich? So wie es geschrieben steht, passt eine leere Abfrage direkt aus dem Konstruktor auf alle Zeichen, also musst du nur die Felder setzen, nach denen du filtern willst.
Angesichts des klaren Musters in den realen Anwendungsfällen wäre es besser gewesen, einfach das eigentliche Problem zu lösen:
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
=
Color
::
Invalid
,
Location
location
=
Location
::
Invalid
,
float
distance
=
0.0f
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
isColorValid
(
color
)
&&
sign
->
color
()
!=
color
)
{
continue
;
}
if
(
isLocationValid
(
location
)
&&
getDistance
(
sign
->
location
(),
location
)
>
distance
)
{
continue
;
}
return
sign
;
}
return
nullptr
;
}
Du wirst mir jetzt vielleicht vorwerfen, dass ich schummle. Sicher, jetzt, wo die ersten Anwendungsfälle auf dem Tisch liegen, scheint es, dass findSignWithColorNearLocation
eine bessere Lösung ist als SignQuery
- aber das hättest du nach dem ersten Anwendungsfall nicht vorhersagen können. Es war nicht wahrscheinlicher, findSignWithColorNearLocation
als allgemeine Lösung zu schreiben, als es SignQuery
war. Einer der Anwendungsfälle hätte mehrere Farben zulassen können oder sich auf den Text der Zeichen beziehen können.
Das ist genau mein Punkt! Nach einem Anwendungsfall war keine allgemeine Lösung vorhersehbar, also war es ein Fehler, zu versuchen, eine zu schreiben. Sowohl findSignWithColorNearLocation
als auch SignQuery
sind Fehler. Hier gibt es keinen Gewinner, nur zwei Verlierer.
Hier ist der beste Weg, ein rotes Schild zu finden:
Sign
*
findRedSign
(
const
vector
<
Sign
*>
&
signs
)
{
for
(
Sign
*
sign
:
signs
)
if
(
sign
->
color
()
==
Color
::
Red
)
return
sign
;
return
nullptr
;
}
Ja, ich meine es ernst. Ich würde vielleicht eine passende Farbe eingeben, aber weiter würde ich nicht gehen. Wenn du einen Anwendungsfall hast, schreibe Code, um diesen Anwendungsfall zu lösen. Versuche nicht zu erraten, was der zweite Anwendungsfall sein wird. Schreibe Code, um Probleme zu lösen, die du verstehst, und nicht solche, bei denen du nur raten kannst .
Ein offensichtlicher Einwand gegen diese Strategie, auf den ich mit Nachdruck antworte
"Moment mal", sagst du vielleicht an dieser Stelle. "Wenn du einen Code schreibst, der kaum die Anforderungen des Anwendungsfalls erfüllt, ist dann nicht garantiert, dass du auf Anwendungsfälle stößt, die der Code nicht bewältigen kann? Was tust du, wenn der nächste Anwendungsfall auftaucht, der nicht zu dem Code passt, den du geschrieben hast? Das scheint unausweichlich zu sein."
"Und ist das nicht ein Argument dafür, allgemeineren Code zu schreiben? Sicher, die ersten fünf Anwendungsfälle, auf die wir mit SignQuery
gestoßen sind, haben den Code, den wir geschrieben haben, nicht in Anspruch genommen, aber was ist, wenn der sechste Anwendungsfall doch eintritt? Wären wir dann nicht froh, wenn wir den Code für SignQuery
schon fertig geschrieben hätten?"
Nein, nicht wirklich. Erspare deinen Aufwand. Wenn ein Anwendungsfall auftaucht, den dein Code nicht abdeckt, schreibe einen Code, der ihn abdeckt. Du könntest deinen ersten Versuch ausschneiden und einfügen und dabei Anpassungen vornehmen, um den neuen Anwendungsfall zu behandeln. Oder du fängst wieder von vorne an. Beides ist in Ordnung.
Der erste Anwendungsfall in der Liste von fünf war "Finde ein rotes Schild", und ich habe Code geschrieben, um genau das zu tun und nicht mehr. Der zweite Anwendungsfall war "Finde ein Schild an der Ecke Main Street und Barr Street", also schreibe ich jetzt einen Code, der genau das tut und nicht mehr:
Sign
*
findSignNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Location
location
,
float
distance
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
getDistance
(
sign
->
location
(),
location
)
<=
distance
)
{
return
sign
;
}
}
return
nullptr
;
}
Der dritte Anwendungsfall war "Finde ein rotes Schild in der Nähe von 212 South Water Street", und dieser wird von keiner der beiden Funktionen, die ich geschrieben habe, behandelt. Das ist der Wendepunkt - jetzt, wo wir drei unabhängige Anwendungsfälle haben, macht es Sinn, zu verallgemeinern. Mit drei unabhängigen Anwendungsfällen können wir den vierten und fünften mit größerer Sicherheit vorhersagen.
Warum drei? Was macht die Drei zu einer magischen Zahl? Eigentlich nichts, außer der Tatsache, dass sie nicht eins oder zwei ist. Ein Beispiel reicht nicht aus, um das allgemeine Muster zu erraten. Meiner Erfahrung nach reichen auch zwei nicht aus - nach zwei Beispielen bist du dir deiner ungenauen Verallgemeinerung nur noch sicherer. Bei drei verschiedenen Beispielen wird deine Vorhersage des Musters genauer sein und du wirst wahrscheinlich etwas vorsichtiger bei deiner Verallgemeinerung sein. Es gibt nichts Besseres, als nach den Beispielen eins und zwei falsch zu liegen, um bescheiden zu werden!
Trotzdem musst du an dieser Stelle nicht verallgemeinern! Es wäre völlig in Ordnung, eine dritte Funktion zu schreiben, ohne die ersten beiden Funktionen in sie einzufalten :
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
,
Location
location
,
float
distance
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
sign
->
color
()
==
color
&&
getDistance
(
sign
->
location
(),
location
)
>=
distance
)
{
return
sign
;
}
}
return
nullptr
;
}
Dieser Ansatz mit drei getrennten Funktionen hat einen wichtigen Vorteil: Die Funktionen sind sehr einfach. Es ist offensichtlich, welche Funktion du aufrufen musst. Wenn du eine Farbe und einen Ort hast, rufst du findSignWithColorNearLocation
auf. Wenn du nur eine Farbe hast, ist es findSignWithColor
; wenn du nur einen Ort hast, ist es findSignNearLocation
.1
Wenn deine Anwendungsfälle zum Finden von Schildern weiterhin nach einer einzigen Farbe und/oder einem einzigen Ort suchen, werden diese drei Funktionen für immer ausreichen. Der Ansatz ist natürlich nicht sehr gut skalierbar - mit zwei separaten Argumenten und drei separaten findSign
Funktionen ist der Ansatz keine Katastrophe, aber mit mehr möglichen Argumenten wird er schnell lächerlich. Wenn du irgendwann einen Anwendungsfall hast, bei dem es darum geht, den Zeichentext zu betrachten, wirst du wahrscheinlich davor zurückschrecken, sieben Varianten der Funktion findSign
zu erstellen.
An dieser Stelle spricht nichts dagegen, die drei Funktionen von findSign
zu einer einzigen Funktion zu kombinieren, die alle drei Fälle abdeckt. Sobald du drei verschiedene Anwendungsfälle hast, ist es sicherer, zu verallgemeinern. Aber verallgemeinere nur dann, wenn du denkst, dass der Code dadurch einfacher zu schreiben und zu lesen ist, und zwar ausschließlich auf der Grundlage der vorliegenden Anwendungsfälle. Verallgemeinere nie, weil du dir Sorgen um den nächsten Anwendungsfall machst - verallgemeinere nur für die Anwendungsfälle, die du kennst.
Das Schreiben von verallgemeinertem Code in C++ ist ein wenig mühsam, weil C++ keine optionalen Argumente kennt, sondern nur Standardwerte für Argumente. Das bedeutet, dass wir einen Weg finden müssen, um unsere Argumente als "nicht vorhanden" zu markieren. Eine Lösung ist, Invalid
Werte für Farbe und Ort hinzuzufügen, die wir verwenden können, wenn sie uns nicht wichtig sind. Wir wiederholen die erste Version von findSignWithColorNearLocation
:
Sign
*
findSignWithColorNearLocation
(
const
vector
<
Sign
*>
&
signs
,
Color
color
=
Color
::
Invalid
,
Location
location
=
Location
::
Invalid
,
float
distance
=
0.0f
)
{
for
(
Sign
*
sign
:
signs
)
{
if
(
isColorValid
(
color
)
&&
sign
->
color
()
!=
color
)
{
continue
;
}
if
(
isLocationValid
(
location
)
&&
getDistance
(
sign
->
location
(),
location
)
>
distance
)
{
continue
;
}
return
sign
;
}
return
nullptr
;
}
Mit dieser Funktion, die geschrieben wurde, können alle Aufrufe von findSignWithColor
und findSignNearLocation
durch Aufrufe von findSignWithColorNearLocation
ersetzt werden.
Eigentlich ist es schlimmer als YAGNI
Bisher hast du auf gesehen, dass verfrühte Verallgemeinerungen dazu führen, dass du Code schreibst, der nie ausgeübt wird, und das ist schlecht. Das weniger offensichtliche Problem ist, dass verfrühte Verallgemeinerungen die Anpassung an unvorhergesehene Anwendungsfälle erschweren. Das liegt zum einen daran, dass der verallgemeinerte Code, den du geschrieben hast, komplizierter ist und deshalb mehr Arbeit macht, um ihn anzupassen, aber es gibt auch einen subtileren Grund. Wenn du erst einmal eine Vorlage für die Verallgemeinerung erstellt hast, wirst du diese Vorlage wahrscheinlich für zukünftige Anwendungsfälle erweitern, anstatt sie neu zu bewerten.
Rolle die Uhr ein wenig zurück. Stell dir vor, du hättest die Klasse SignQuery
schon früh verallgemeinert, aber dieses Mal sehen die ersten Anwendungsfälle so aus:
-
Finde ein rotes Schild.
-
Finde ein rotes "STOP"-Schild in der Nähe der Ecke Main Street und Barr Street.
-
Finde alle roten oder grünen Schilder auf der Main Street.
-
Finde alle weißen Schilder mit dem Text "MPH" auf der Wabash Avenue oder Water Street.
-
Finde ein Schild mit der Aufschrift "Lane" oder in blauer Farbe in der Nähe der 902 Mill Street.
Die ersten beiden Anwendungsfälle in dieser Liste passen ziemlich gut zu SignQuery
, aber dann beginnen die Dinge auseinanderzufallen.
Der dritte Anwendungsfall, "Finde alle roten oder grünen Schilder in der Hauptstraße", stellt zwei neue Anforderungen. Erstens muss der Code alle übereinstimmenden Schilder zurückgeben und nicht nur ein einziges. Das ist nicht schwer:
vector
<
Sign
*>
findSigns
(
const
SignQuery
&
query
,
const
vector
<
Sign
*>
&
signs
)
{
vector
<
Sign
*>
matchedSigns
;
for
(
Sign
*
sign
:
signs
)
{
if
(
query
.
matchSign
(
sign
))
matchedSigns
.
push_back
(
sign
);
}
return
matchedSigns
;
}
Die zweite neue Anforderung ist, alle Schilder entlang einer Straße zu finden, und das ist schwieriger. Wenn man davon ausgeht, dass Straßen als eine Reihe von Liniensegmenten dargestellt werden können, die Orte miteinander verbinden, können sowohl Orte als auch Straßen in einer neuen Area
struct zusammengefasst werden:
struct
Area
{
enum
class
Kind
{
Invalid
,
Point
,
Street
,
};
Kind
m_kind
;
vector
<
Location
>
m_locations
;
float
m_maxDistance
;
};
static
bool
matchArea
(
const
Area
&
area
,
Location
matchLocation
)
{
switch
(
area
.
m_kind
)
{
case
Area
::
Kind
::
Invalid
:
return
true
;
case
Area
::
Kind
::
Point
:
{
float
distance
=
getDistance
(
area
.
m_locations
[
0
],
matchLocation
);
return
distance
<=
area
.
m_maxDistance
;
}
break
;
case
Area
::
Kind
::
Street
:
{
for
(
int
index
=
0
;
index
<
area
.
m_locations
.
size
()
-
1
;
++
index
)
{
Location
location
=
getClosestLocationOnSegment
(
area
.
m_locations
[
index
+
0
],
area
.
m_locations
[
index
+
1
],
matchLocation
);
float
distance
=
getDistance
(
location
,
matchLocation
);
if
(
distance
<=
area
.
m_maxDistance
)
return
true
;
}
return
false
;
}
break
;
}
return
false
;
}
Dann kann die neue Area
struct den Ort und die maximale Entfernung in SignQuery
ersetzen:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_area
(),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchArea
(
m_area
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
);
}
vector
<
Color
>
m_colors
;
Location
m_location
;
float
m_distance
;
regex
m_textExpression
;
};
Der vierte Anwendungsfall fragt nach allen Geschwindigkeitsbegrenzungsschildern auf einer von zwei Straßen, was nicht passt. Es ist einfach genug, eine Liste von Gebieten zu unterstützen:
bool
matchAreas
(
const
vector
<
Area
>
&
areas
,
Location
matchLocation
)
{
if
(
areas
.
empty
())
return
true
;
for
(
const
Area
&
area
:
areas
)
if
(
matchArea
(
area
,
matchLocation
))
return
true
;
return
false
;
}
Dann kannst du den einzelnen Bereich in SignQuery
durch eine Liste ersetzen:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_areas
(),
m_textExpression
(
".*"
)
{
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchAreas
(
m_areas
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
);
}
vector
<
Color
>
m_colors
;
vector
<
Area
>
m_areas
;
regex
m_textExpression
;
};
Der fünfte Anwendungsfall ist ein echter Mix: Er sucht nach einem Schild, das einen Punkt von historischem Interesse markiert. Diese Schilder sind normalerweise blau, also wird danach gesucht, aber sie können auch grün sein und einen bestimmten Text enthalten. Das passt nicht in das Modell auf SignQuery
.
Auch das ist nicht unmöglich. Das Hinzufügen von booleschen Operationen zu SignQuery
adressiert den neuen Anwendungsfall:
struct
SignQuery
{
SignQuery
()
:
m_colors
(),
m_areas
(),
m_textExpression
(
".*"
),
m_boolean
(
Boolean
::
None
),
m_queries
()
{
;
}
~
SignQuery
()
{
for
(
SignQuery
*
query
:
m_queries
)
delete
query
;
}
enum
class
Boolean
{
None
,
And
,
Or
,
Not
};
static
bool
matchBoolean
(
Boolean
boolean
,
const
vector
<
SignQuery
*>
&
queries
,
const
Sign
*
sign
)
{
switch
(
boolean
)
{
case
Boolean
::
Not
:
return
!
queries
[
0
]
->
matchSign
(
sign
);
case
Boolean
::
Or
:
{
for
(
const
SignQuery
*
query
:
queries
)
if
(
query
->
matchSign
(
sign
))
return
true
;
return
false
;
}
break
;
case
Boolean
::
And
:
{
for
(
const
SignQuery
*
query
:
queries
)
if
(
!
query
->
matchSign
(
sign
))
return
false
;
return
true
;
}
break
;
}
return
true
;
}
bool
matchSign
(
const
Sign
*
sign
)
const
{
return
matchColors
(
m_colors
,
sign
->
color
())
&&
matchAreas
(
m_areas
,
sign
->
location
())
&&
regex_match
(
sign
->
m_text
,
m_textExpression
)
&&
matchBoolean
(
m_boolean
,
m_queries
,
sign
);
}
vector
<
Color
>
m_colors
;
vector
<
Area
>
m_areas
;
regex
m_textExpression
;
Boolean
m_boolean
;
vector
<
SignQuery
*>
m_queries
;
};
Uff. Das war eine anspruchsvollere Reihe von Anwendungsfällen als die, die wir am Anfang dieser Regel gesehen haben. Nachdem wir eine Menge Änderungen vorgenommen haben, kann das QuerySign
Modell jedoch eine breite Palette von Anfragen bearbeiten. Es gibt zwar immer noch sinnvolle Anfragen, die nicht beantwortet werden können - z. B. "Finde zwei Schilder, die nicht weiter als 10 Meter voneinander entfernt sind" -, aber man kann sich leicht vorstellen, dass wir die wichtigen Fälle abgedeckt haben. Sieg, richtig?
So sieht Erfolg nicht aus
Eigentlich ist es nicht klar, dass die Erweiterung von SignQuery
uns in eine gute Position gebracht hat, auch wenn ich sehr fair war - es gibt kein YAGNI in den Erweiterungen und ich habe alles so sauber und ordentlich gehalten, wie ich konnte.
Wenn du eine allgemeine Lösung immer weiter ausbaust, kannst du den Kontext aus den Augen verlieren. Genau das ist hier passiert.
Vergleichen wir die Lösung dieses letzten Anwendungsfalls mit SignQuery
mit der direkten Lösung des gleichen Problems. Hier ist die Lösung von SignQuery
:
SignQuery
*
blueQuery
=
new
SignQuery
;
blueQuery
->
m_colors
=
{
Color
::
Blue
};
SignQuery
*
locationQuery
=
new
SignQuery
;
locationQuery
->
m_areas
=
{
mainStreet
};
SignQuery
query
;
query
.
m_boolean
=
SignQuery
::
Boolean
::
Or
;
query
.
m_queries
=
{
blueQuery
,
locationQuery
};
vector
<
Sign
*>
locationSigns
=
findSigns
(
query
,
signs
);
Und hier ist die direkte Version:
vector
<
Sign
*>
locationSigns
;
for
(
Sign
*
sign
:
signs
)
{
if
(
sign
->
color
()
==
Color
::
Blue
||
matchArea
(
mainStreet
,
sign
->
location
()))
{
locationSigns
.
push_back
(
sign
);
}
}
Die direkte Lösung ist besser. Sie ist einfacher, sie ist leichter zu verstehen, sie ist leichter zu debuggen und sie ist leichter zu erweitern. Die ganze Arbeit, die wir an SignQuery
geleistet haben, hat uns nur immer weiter von der einfachsten und besten Lösung entfernt. Und das ist die eigentliche Gefahr bei einer verfrühten Verallgemeinerung - nicht nur, dass du Funktionen einführst, die nie genutzt werden, sondern dass du mit deiner Verallgemeinerung eine Richtung festlegst, die nur schwer zu ändern ist.
Verallgemeinerte Lösungen sind wirklich schwer zu finden. Wenn du einmal eine Abstraktion zur Lösung eines Problems eingeführt hast, ist es schwer, sich Alternativen vorzustellen. Sobald du findSigns
benutzt, um alle roten Zeichen zu finden, wirst du instinktiv findSigns
benutzen, wenn du Zeichen jeglicher Art finden musst. Schon der Name der Funktion sagt dir, dass du das tun sollst!
Wenn du also ein Gehäuse hast, das nicht ganz passt, ist die naheliegende Lösung, SignQuery
und findSigns
zu erweitern, um das neue Gehäuse abzudecken. Das Gleiche gilt für den nächsten Fall, der nicht passt, und den übernächsten. Je ausdrucksstärker die allgemeine Lösung wird, desto umständlicher wird sie auch... und wenn du nicht sehr vorsichtig bist, wirst du gar nicht merken, dass du deine Verallgemeinerung über ihre natürlichen Grenzen hinaus erweitert hast.
Wenn du einen Hammer in der Hand hältst, sieht alles wie ein Nagel aus, richtig? Eine generelle Lösung ist das Verteilen von Hämmern. Mach das erst, wenn du dir sicher bist, dass du einen Sack Nägel statt einen Sack Schrauben hast.2
1 Wenn du eine Sprache wie C++ verwendest, die das Überladen von Funktionen unterstützt, kannst du auch alle drei Versionen von findSign
aufrufen und den Compiler die Sache regeln lassen.
2 Du kannst übrigens einen Hammer benutzen, um eine Schraube anzutreiben. Du musst den Hammer nur fester schwingen. Auch auf die Gefahr hin, schmerzhaft offensichtlich zu sein, gilt das Gleiche für Code. Du kannst Dinge mit einer ungünstigen Abstraktion zum Laufen bringen - du musst die Abstraktion nur fester schwingen.
Get Die Regeln der Programmierung 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.