Das war mir doch ein Experiment wert: Wie würden es Entwickler aufnehmen, wenn quasi alle Parameter von Methoden (zumindest in den Kontrakten von Komponenten) nicht primitiv sein sollen? Auf dem Coding Dojo in München habe ich es ausprobiert.
Das Beispielszenario dort war eine Rechtschreibprüfungsanwendung. In der gibt es dann irgendwo eine Funktion WortKorrekt() o.ä. Deren Signatur würde üblicherweise so aussehen:
bool WortKorrekt(string wort)
Darin sind zwei primitive Typen zu finden: bool und string.
Ich bin nun der Meinung, dass wir uns anstrengen sollten, solche Typen (zumindest in den Kontrakten von Komponenten) zu vermeiden. Statt unspezifischer primitiver Typen sollten wir spezifische Typen passend zur Problemdomäne definieren.
Codegrundlage Ubiquitous Language
Über die Problemdomäne sprechen wir unter uns und mit dem Kunden in der Ubiquitous Language (UL). Es ist deshalb wichtig, sie in unserem Code wiederzufinden. Sonst müssen wir ständig Aufwand treiben, um unsere Rede in Code (und wieder zurück) zu übersetzen. Code würde weniger verständlich. Und wir wären unsicherer, ob wir unseren Code geeignet strukturiert haben, denn die Begriffe der UL sollten darin repräsentiert werden.
Damit wir uns der UL bewusst werden, habe ich im Dojo mit den Teilnehmern eine Concept Map der UL für die Domäne “Rechtschreibprüfung” erarbeitet:
Die sieht ein bisschen wüst aus, vor allem, weil man meine Schrift schon nach wenigen Minuten unleserlich wird ;-) Doch mir gehts hier nicht um Einzelheiten, sondern einfach mal eine Impression.
Die Concept Map enthält für jeden Begriff der Domänensprache einen “Kuller”. Unterschieden wird dabei nicht zwischen Daten und Diensten, Verben und Substantiven. Was wichtig ist, wird in einen Kreis gesetzt. Und dann werden die Begriffe verbunden mit qualifizierten Beziehungen. Ein “Wörterbuch” (Begriff) ist z.B. “abgefasst in” (Beziehung) einer “Sprache” (Begriff).
Der Vorteil einer Concept Map (z.B. statt eines Klassendiagramms) in einem frühen Stadium der Planung ist seine Informalität. Es gibt nur wenige Regeln zu beachten. Sie können Ihren Gedanken freien Lauf lassen. Ziel ist Vollständigkeit in Bezug auf Begriffe/Konzepte und Beziehungen auf einem hohen Abstraktionsniveau. Concept Maps sind Landkarten des Begriffsterrains der Problemdomäne.
Worauf ich nun hinaus will: der Concept Map können Sie die grundlegenden Funktionseinheiten und Daten der Problemdomäne entnehmen. Sie dient mithin der Hinführung zur Implementierung. Die Konzepte “Prüfer” und “Parser” in der Concept Map der Rechtschreibkontrolle sollten also z.B. als Services implementiert werden. (Nein, ich meine nicht SOA-Services, sondern benutze den Begriff “Service” für dienstleistungsorientierte Funktionseinheiten im Gegensatz zu solchen, die eher Daten halten.)
“Wörterbuch” hingegen hört sich eher wie eine Datenstruktur oder zumindest eine Entität an.
Soweit so normal. Sie wären auf diese Konzepte und ihre spätere Implementierung als Klassen vielleicht auf anderem Weg gekommen; identifiziert hätten Sie sie jedoch allemal.
Jetzt aber zu Prüftext, Prüfwort und Fehlerwort. Das sind ebenfalls Begriffe der Domänensprache. Doch wie übersetzen Sie die in Code? Das sind keine Services, keine Entitäten, nicht mal “echte” Datenstrukturen. Der Prüftext ist eher nur ein Text in einer bestimmten Situation. Dito das Prüfwort und das Fehlerwort. Prüfwort und Fehlerwort mögen sogar dieselben Texte sein, einmal vor der Prüfung und einmal hinterher.
Da liegt es doch nahe, diese Begriffe in string-Parameter/Felder zu übersetzen, oder? Die obige Funktion tut das exemplarisch und sieht normal aus, oder? Immerhin haben diese Begriffe keine weiteren Eigenschaften, sind nur “im Fluss” zwischen Services zu finden und ihre Daten müssen auch nicht persistiert werden. Einer so simplen Übersetzung steht daher nichts im Wege.
Primitiven der Ubiquitous Language codieren
Die klassische OOA/OOD kennt natürlich die Repräsentation von Domänenbegriffen als Klassen. Aus einem “Kunden” wird die class Customer, aus einem “Termin” die class Appointment, aus einem Auftragsstatus ein enum OrderState usw. Begriffe, die offensichtliche Eigenschaften haben oder zusammengesetzt sind und Daten repräsentieren, werden in class oder struct übersetzt.
Eine Rechnung besteht aus einer Rechnungsnummer, einem Rechnungsdatum, einem Kunden und vielem mehr. Die Übersetzung sieht dafür meist so aus:
class Invoice
{
public string InvoiceNumber { get; set; }
public DateTime InvoiceDate { get; set; }
public Customer Customer { get; set; }
…
}
Rechnung und Kunde haben eigene Datentypen bekommen, alle anderen Informationen sind als primitiv eingestuft und mit Standarddatentypen definiert.
Dem möchte ich nun entgegenhalten, auch Primitiven der Domänensprache durch eigene Datentypen zu repräsentieren. Ja, genau: Auch wenn ein Begriff nur für eine ganze Zahl oder eine Zeichenkette steht, sollten Sie ihm eine Klasse (oder eine Struktur) spendieren. Die Implementierung der Rechnung würde dann z.B. so aussehen:
class Invoice{
public InvoiceNumber Number { get; set; }
public InvoiceDate CreatedAt { get; set; }
public Customer Customer { get; set; }
…
}
Oder die Prüfroutine der Rechtschreibkontrolle würde so aussehen:
bool WortKorrekt(PrüfWort wort)
Warum das? Ist nicht bool WortKorrekt(string wort) genauso gut zu lesen, genauso aussagekräftig wie bool WortKorrekt(PrüfWort wort)?
Landläufig betrachtet liegen beide in puncto Verständlichkeit nahe beieinander. Doch ich behaupte, dass Code, der weniger mit primitiven oder allgemeinen Typen des .NET Framework arbeitet und stattdessen konkrete Typen der UL benutzt, typsicherer ist und semantisch weniger Zweifel lässt.
Am Ort der Definition mag das weniger sichtbar sein als am Ort der Nutzung:
if (prüfer.WortKorrekt(new PrüfWort(…)))
oder
var inv = new Invoice(InvoiceNumber.Create(), InvoiceDate.Today(), …);
lassen weniger Zweifel, ob zusammenpasst, was zusammenpassen soll.
Einer Zeichenkette “Daten” können Sie nicht ansehen, was sie bedeutet. Es ist einfach nur eine Zeichenkette. Aber new PrüfWort(“Daten”) ist ganz eindeutig etwas anderes als new Dateiname(“Daten”). Ebenso ist new InvoiceDate(“2010-06-01”) (der erste Tag im Monat Juni) etwas anderes als new InvoiceNumber(“2010-06-01”) (die erste Rechnung im Monat Juni).
Die übliche Objektorientierung richtet ihr Augenmerk auf Objekte, d.h. “Dinger mit Eigenschaften”. Ich möchte Sie motivieren, genauer hinzusehen. Zoomen Sie näher heran und sehen Sie die Eigenschaft als letztlich nichts anderes als die Objekte. Die Welt ist rekursiv. Sie besteht aus “Objekten”, die aus “Objekten” bestehen, die aus “Objekten” bestehen usw. Und auf jeder Ebene kann es Begriffe der Ubiquitous Language geben.
Im Beispiel besteht ein Prüftext aus Prüfworten. Das versteht ein Entwickler wie auch der Kunde. Wird das bei der Codierung nur in die Zerlegung einer Zeichenkette in Zeichenketten übersetzt, dann entsteht eine Diskrepanz zwischen Sprache und Code. Der Grundstein für Missverständnisse ist gelegt.
So sollte in der Rechtschreibkontrolle also die Service-Funktionseinheit Parser besser wie folgt definieren:
class Parser
{
IEnumerable<Prüfwort> ZerlegeText(Prüftext text) { … }
}
mit
class Prüftext
{
public string Text;
}
class Prüfwort
{
public string Wort;
}
(Dass ich hier der Klasse Prüftext keine Methode gebe, über die man an ihre Worte herankommt, möchte ich undiskutiert lassen. Das ist ein Thema für einen anderen Blogartikel.)
Zusammenfassung
Seien Sie rigoros bei der Implementierung. Nehmen Sie die Ubiquitous Language Ihrer Problemdomäne ernst. Repräsentieren Sie alle Begriffe durch “Kontrakte” (Interface/Klasse, Struktur, Methodensignatur). Primitive Typen des .NET Framework sollten “im API” von Komponenten auf das Nötigste beschränkt sein. Stattdessen übersetzen Sie selbst primitive Konzepte der Domäne in eigene Typen.
Sie machen damit ihren Code leichter lesbar und auch leichter veränderbar. Denn wenn heute ein Prüfwort womöglich nur eine Zeichenkette ist, dann könnte es morgen eine Zeichenkette mit einer Position in einem Text sein.
class Prüfwort{
public string Wort;
public Textposition Position;
}
Und übermorgen ist es eine Zeichenkette mit einer Position in einer Sprache.
class Prüfwort{
public string Wort;
public Textposition Position;
public Wörterbuchsprache Sprache;
}
Machen Sie Ihren Code an jeder Stelle so präzise und domänenorientiert wie möglich.
14 Kommentare:
Und wieder einmal: F# to the rescue!
Mit F# Type Abbreviations kannst du einem primitiven oder komplexen Datentyp einen Alias verpassen.
In deinem Beispiel:
type Prüfwort = string
type Prüftext = string
oder
type ZerlegterText = IEnumerable[string]
Es müssen keine zusätzlichen Klassen definiert werden, wovor sich die meisten Entwickler scheuen, vieleicht sogar mehr scheuen as vor langen Parameternamen.
Wenn du von primitiven Datentypen spricht, meinst du da die in der CLR nativen Datentypen, oder ValueTypes? Was macht einen primitiven Datentyp aus? Ist nicht mitlerweile ein IEnumerable[T] ein primitiver "Daten"Typ. Konsequenterweise müsstest du statt dem Rückgabeparameter IEnumerable[T] ein benanntes Interface zurückgeben, z.B.
interface IZerlegteWörter : IEnumerable[string] {}
Benjamin
@Benjamin: Schöne Idee mit dem Abbreviations. Nur leider funktioniert das wohl nicht in der Interop mit C#, oder? (Muss ich mal ausprobieren.)
Was ist ein .NET primitiver Typ? Na, die primitiven Typen halt ;-) inkl. string.
Array, und IEnumerable<> sehe ich als "Modulationen" davon. Ein Prüfwort[] muss ich nicht mehr extra Verpacken in eine PrüfwortListe.
-Ralf
Hallo,
was du vorschlägst hat auch den positiven Seiteneffekt, dass sich viele DI-Container mit der Auflösung leichter tun, bzw. sie dadurch ohne große Konfiguration möglich wird. Eine Klasse, die einen String injectet bekommen möchte muss immer aufwändiger konfiguriert werden, als wenn sie ein Objekt des Typs "RepositoryPath" erwartet - der praktischerweise ein Singleton ist und automatisch injiziert wird :)
Ich stolpere im Moment auch immer öfter über Methoden mit int und String als Übergabeparameter - wenn sie dann noch schlecht benannt sind, weiß man weder vor noch zurück und muss fremden Code lesen, um die Methode richtig anzuwenden.
Ich würde daher immer auf eine Kapselung in Klassen (oder Abbreviations, sollte ich mal was in F# machen) hinweisen, bevor man unbedacht die primitiven Datentypen verwendet. Viele Kopfschmerzen können damit bei späteren Änderungen vermieden werden.
Hallo Ralf,
genau zu diesem Thema will ich meinen ersten Blog schreiben für das CCD Praktikum. Kürzlich hatte ich genau diesen Fall, dass ich im Rahmen eines größeren Refactorings vor der Wahl stand, einen int oder einen primitiven Datentypen zu nehmen.
Ich habe mich in weiser Voraussicht für letzteres entschieden. Das hat mir im Nachhinein betrachtet viel, viel Arbeit gespart.
Dazu mehr, wenn ich am Feiertag (oje...) Zeit für den Blog habe.
Steven
Hallo zusammen
Der Ansatz macht Sinn. Gerade auch mit generischen typen wie Generic.Dictionary(Of Int, String) stellt sich oft die Frage was für ein Key und was für ein Value da erwartet werden.
Da ich mich erst seit kurzem mit CCD befasse ist mir der Aufbau noch nicht so klar. Würde das idealerweise heissen:
1. Assembly (xyTypes.dll) in F# mit den Abbreviations Typen
2. Assembly (xyContract.dll) in C# mit den Interfacex mit einem Verweis auf xyTypes
3. Assembly (xy.dll) in C# mit dem eigentlichen Code und Verweisen auf xyTypes und xyContract
Wie schon Benjamin Fragte ist mir nicht ganz klar, was ich im Bezug auf EBC aufgeschnappt habe. Da wir häufig mit Action oder so ähnlich gearbeitet. Wie weiss ich da, was übergeben werden muss?
Andreas
Bei Action ist das [T] rausgefallen
@Andreas: Mit einer polyglotten Codebasis würde ich nicht gleich anfangen, nur weil du Domänenprimitiven auch durch eigene Typen repräsentieren willst.
(Da fällt mir allerdings noch ein Feature von F# ein, das in diesem Zusammenhang hilt: units of measure.)
Wie weiß man, was bei einem EBC-Event übergeben werden muss? Na, das sieht man halt am Typ:
event Action On_TextPrüfenlassen;
-Ralf
@Ralf
wegen Action[T] war ich in Gedanken beim Asynchronbespiel und da muss es logischerwiese offen sein. Habe ich jetzt, glaub ich, verstanden.
Zum Aufbau: Aber ist so, dass für Typen, Kontrakte und eigentlicher Funktionscode je eine Assembly erstellt werden muss?
Danke im Voraus für die Beantwortung meiner Fragen.
Andreas
@Andreas: Eigene Assemblies sind immer da nötig, wo Informationen verborgen (oder unveränderlich gemacht) werden sollen und Parallelarbeit ermöglicht werden soll.
Kontrakte teile ich im Augenblick in 2 Assemblies: eine für Servicekontrakte (lies: Interfaces), eine andere für Messagekontrakte (lies: Datenklassen).
Und Serviceimplementationen liegen in eigenen Assemblies. Aber wie du sie darin zusammenfasst, kannst du dir überlegen. Die Chance für Mergekonflikte sollte minimal sein.
-Ralf
Hi Ralf,
Wiedereinmal interessant :-)
Einige Fragen haben sich mir sofort aufgedrängt, als ich das gelesen habe:
1) Wieso wird IEnumerable nicht zu PrüfwortListe. Ich habe gelesen in den Kommentaren, dass du das als Modulationen ansiehst. Wieso hältest dus nicht für nötig die umzuformulieren? IEnumerable ist schon sehr unleserlich und Domänenfremd...?
2) Gerade eine allgemeine Frage zur Ubiquitous Language: Du hast jetzt Prüfwort, also Deutsch. Wie handhabst du das? Quelltext immer englisch oder deutsch oder immer in der Sprache des Kunden?
3) Aufwendiger wirds einfach mit dem Tooling, wenn man die primitiven Typen nicht mehr verwendet. Vorallem wenns um Interops geht, z.B. mit Java und in verteilten Systemen usw. Primitive Typen werden halt von fast allen Tools usw. unterstützt.
4) Das mit den Aliasen wie mit den Abbreviations - geht das in C# nicht auch z.B. mit using oder präprozessor-Anweisungen? Reicht das nicht?
Ansonsten, gerade in Punkto erweiterung errinnerts mich sehr an SOA Lösungen welche ja meistens auch immer einen Custom Type zurückgeben und einen hineinkriegen, damit nicht die ganze Schnittstelle ändert wenn ein Parameter wegfällt.
Viele Grüsse :-)
Laurin
@Laurin: Zu 1) IEnum finde ich nicht unleserlich. Im Gegenteil. Davon abgesehen halte ich es aber für durchaus relevant, dem Nachrichtentyp anzusehen, ob er für einen oder viele Werte steht. Geht es um einen Strom oder einen Wert?
Aber du kannst es ja anders machen. Konventionen dürfen lokal sein.
Zu 2) Mach, wie du willst. Du musst es lesen können. Sei nur konsequent.
Zu 3) Wir sollten uns nicht vom Tooling regieren lassen. Lesbarkeit geht vor einfachem Tooleinsatz, würd ich mal sagen.
Zu 4) Präprozessor in C# der irgendetwas ersetzt? Kenn ich nicht.
Und schließlich: An SOA war ja nicht alles schlecht ;-)
Hi Ralf,
Danke für die zügige Antwort.
Aber mit Using gehts ja oder?
Using myString = string
Habe leider kein VS da - aber gehts nicht? Würde das dann nicht auch reichen?
Viele Grüsse
laurin
@Laurin: Ne, das geht nicht mit using. Ein Alias kann nur für einen Namespace definiert werden. Wir reden über C#, eine Sprache, die versucht, dem Prinzip des least astonishment zu dienen ;-)
Kommentar veröffentlichen
Hinweis: Nur ein Mitglied dieses Blogs kann Kommentare posten.