it-swarm.com.de

Entwerfen einer Klasse, um ganze Klassen als Parameter und nicht als einzelne Eigenschaften zu verwenden

Angenommen, Sie haben eine Anwendung mit einer weit verbreiteten Klasse namens User. Diese Klasse enthält alle Informationen über den Benutzer, seine ID, seinen Namen, die Zugriffsebenen auf jedes Modul, die Zeitzone usw.

Die Benutzerdaten werden offensichtlich im gesamten System häufig referenziert, aber aus irgendeinem Grund ist das System so eingerichtet, dass wir dieses Benutzerobjekt nicht an davon abhängige Klassen übergeben, sondern nur einzelne Eigenschaften daraus übergeben.

Für eine Klasse, für die die Benutzer-ID erforderlich ist, ist lediglich die GUID userId als Parameter erforderlich. Manchmal benötigen wir auch den Benutzernamen, sodass dieser als separater Parameter übergeben wird In einigen Fällen wird dies an einzelne Methoden übergeben, sodass die Werte überhaupt nicht auf Klassenebene gehalten werden.

Jedes Mal, wenn ich Zugriff auf eine andere Information aus der User-Klasse benötige, muss ich Änderungen durch Hinzufügen von Parametern vornehmen. Wenn das Hinzufügen einer neuen Überladung nicht angemessen ist, muss ich auch jeden Verweis auf die Methode oder den Klassenkonstruktor ändern.

Der Benutzer ist nur ein Beispiel. Dies ist in unserem Code weit verbreitet.

Habe ich Recht, wenn ich denke, dass dies eine Verletzung des Open/Closed-Prinzips ist? Nicht nur die Änderung bestehender Klassen, sondern deren erstmalige Einrichtung, sodass in Zukunft sehr wahrscheinlich weitreichende Änderungen erforderlich sind?

Wenn wir nur das Objekt User übergeben würden, könnte ich eine kleine Änderung an der Klasse vornehmen, mit der ich arbeite. Wenn ich einen Parameter hinzufügen muss, muss ich möglicherweise Dutzende von Änderungen an Verweisen auf die Klasse vornehmen.

Werden andere Prinzipien durch diese Praxis gebrochen? Abhängigkeitsinversion vielleicht? Obwohl wir nicht auf eine Abstraktion verweisen, gibt es nur eine Art von Benutzer, sodass keine wirkliche Notwendigkeit für eine Benutzeroberfläche besteht.

Gibt es andere, nicht SOLID-Prinzipien, gegen die verstoßen wird, wie z. B. grundlegende Prinzipien der defensiven Programmierung?

Sollte mein Konstruktor so aussehen:

MyConstructor(GUID userid, String username)

Oder dieses:

MyConstructor(User theUser)

Hat es geposted:

Es wurde vorgeschlagen, die Frage unter "Pass ID oder Objekt?" Zu beantworten. Dies beantwortet nicht die Frage, wie sich die Entscheidung für einen der beiden Wege auf einen Versuch auswirkt, den Prinzipien von SOLID] zu folgen, die den Kern dieser Frage bilden.

30
Jimbo

Es ist absolut nichts Falsches daran, ein ganzes User Objekt als Parameter zu übergeben. Tatsächlich kann dies dazu beitragen, Ihren Code zu verdeutlichen und Programmierern klarer zu machen, was eine Methode benötigt, wenn für die Methodensignatur ein User erforderlich ist.

Das Übergeben einfacher Datentypen ist schön, bis sie etwas anderes bedeuten als das, was sie sind. Betrachten Sie dieses Beispiel:

public class Foo
{
    public void Bar(int userId)
    {
        // ...
    }
}

Und ein Anwendungsbeispiel:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user.Id);

Können Sie den Defekt erkennen? Der Compiler kann nicht. Die übergebene "Benutzer-ID" ist nur eine Ganzzahl. Wir benennen die Variable user, initialisieren aber ihren Wert aus dem Objekt blogPostRepository, das vermutlich BlogPost Objekte zurückgibt, nicht User Objekte - dennoch kompiliert der Code und Sie am Ende mit einem wackeligen Laufzeitfehler.

Betrachten Sie nun dieses geänderte Beispiel:

public class Foo
{
    public void Bar(User user)
    {
        // ...
    }
}

Möglicherweise verwendet die Methode Bar nur die "Benutzer-ID", für die Methodensignatur ist jedoch ein User -Objekt erforderlich. Kehren wir nun zur gleichen Beispielverwendung wie zuvor zurück, ändern Sie sie jedoch so, dass der gesamte "Benutzer" übergeben wird:

var user = blogPostRepository.Find(32);
var foo = new Foo();

foo.Bar(user);

Jetzt haben wir einen Compilerfehler. Das blogPostRepository.Find Methode gibt ein BlogPost Objekt zurück, das wir geschickt "Benutzer" nennen. Dann übergeben wir diesen "Benutzer" an die Methode Bar und erhalten umgehend einen Compilerfehler, da wir ein BlogPost nicht an eine Methode übergeben können, die ein User akzeptiert.

Das Typsystem der Sprache wird genutzt, um korrekten Code schneller zu schreiben und Fehler zur Kompilierungszeit und nicht zur Laufzeit zu identifizieren.

Wirklich, viel Code umgestalten zu müssen, weil sich Benutzerinformationen ändern, ist nur ein Symptom für andere Probleme. Durch Übergeben eines gesamten User -Objekts erhalten Sie die oben genannten Vorteile, zusätzlich zu den Vorteilen, dass nicht alle Methodensignaturen umgestaltet werden müssen, die Benutzerinformationen akzeptieren, wenn sich etwas an der User -Klasse ändert.

31
Greg Burghardt

Habe ich Recht, wenn ich denke, dass dies eine Verletzung des Open/Closed-Prinzips ist?

Nein, es ist kein Verstoß gegen dieses Prinzip. Bei diesem Prinzip geht es darum, User nicht so zu ändern, dass andere Teile des Codes, die es verwenden, betroffen sind. Ihre Änderungen an User könnten eine solche Verletzung sein, haben aber nichts damit zu tun.

Werden andere Prinzipien durch diese Praxis gebrochen? Abhängigkeitsinversionsperahaps?

Nein. Was Sie beschreiben - nur die erforderlichen Teile eines Benutzerobjekts in jede Methode einfügen - ist das Gegenteil: Es ist eine reine Abhängigkeitsinversion.

Gibt es andere, nicht SOLID-Prinzipien, gegen die verstoßen wird, wie z. B. grundlegende Prinzipien der defensiven Programmierung?

Nein. Dieser Ansatz ist eine absolut gültige Art der Codierung. Es verstößt nicht gegen solche Prinzipien.

Die Umkehrung der Abhängigkeit ist jedoch nur ein Prinzip. Es ist kein unzerbrechliches Gesetz. Und reines DI kann das System komplexer machen. Wenn Sie feststellen, dass nur das Einfügen der erforderlichen Benutzerwerte in Methoden, anstatt das gesamte Benutzerobjekt an die Methode oder den Konstruktor zu übergeben, Probleme verursacht, tun Sie dies nicht auf diese Weise. Es geht darum, ein Gleichgewicht zwischen Prinzipien und Pragmatismus zu finden.

So adressieren Sie Ihren Kommentar:

Es gibt Probleme, einen neuen Wert unnötig in fünf Ebenen der Kette zu analysieren und dann alle Verweise auf alle fünf vorhandenen Methoden zu ändern ...

Ein Teil des Problems hier ist, dass Sie diesen Ansatz laut dem Kommentar "unnötig [bestanden] ..." eindeutig nicht mögen. Und das ist fair genug; Hier gibt es keine richtige Antwort. Wenn Sie es als lästig empfinden, tun Sie es nicht so.

In Bezug auf das Open/Closed-Prinzip ist "... wenn Sie alle Verweise auf alle fünf vorhandenen Methoden ändern ..." ein Hinweis darauf, dass diese Methoden geändert wurden, wenn sie sollten für Änderungen geschlossen. In der Realität ist das Open/Closed-Prinzip für öffentliche APIs zwar sinnvoll, für die Interna einer App jedoch wenig sinnvoll.

... aber ein Plan, sich an dieses Prinzip zu halten, soweit dies praktikabel ist, würde sicherlich Strategien beinhalten, um die Notwendigkeit künftiger Veränderungen zu verringern?

Aber dann wandert man in YAGNI-Territorium und es wäre immer noch orthogonal zum Prinzip. Wenn Sie die Methode Foo haben, die einen Benutzernamen annimmt, und Sie möchten, dass Foo auch ein Geburtsdatum annimmt, fügen Sie nach dem Prinzip eine neue Methode hinzu. Foo bleibt unverändert. Auch dies ist eine gute Vorgehensweise für öffentliche APIs, aber es ist ein Unsinn für internen Code.

Wie bereits erwähnt, geht es um Gleichgewicht und gesunden Menschenverstand für jede Situation. Wenn sich diese Parameter häufig ändern, verwenden Sie User direkt. Dies erspart Ihnen die von Ihnen beschriebenen umfangreichen Änderungen. Aber wenn sie sich nicht oft ändern, ist es auch ein guter Ansatz, nur das zu geben, was benötigt wird.

17
David Arno

Ja, das Ändern einer vorhandenen Funktion verstößt gegen das Open/Closed-Prinzip. Sie ändern etwas, das aufgrund von Änderungen der Anforderungen für Änderungen geschlossen werden sollte. Ein besseres Design (um sich nicht zu ändern, wenn sich Anforderungen ändern) wäre, den Benutzer an Dinge zu übergeben, die für Benutzer funktionieren sollten.

Dies könnte jedoch gegen das Prinzip der Schnittstellentrennung verstoßen, da Sie möglicherweise mehr Informationen weitergeben, als die Funktion für ihre Arbeit benötigt.

Also, wie bei den meisten Dingen - es kommt darauf an.

Wenn Sie nur einen Benutzernamen verwenden, wird die Funktion flexibler und Sie können mit Benutzernamen arbeiten, unabhängig davon, woher sie stammen und ohne dass ein voll funktionsfähiges Benutzerobjekt erstellt werden muss. Es bietet Ausfallsicherheit, wenn Sie glauben, dass sich die Datenquelle ändern wird.

Die Verwendung des gesamten Benutzers macht die Verwendung klarer und schließt einen festeren Vertrag mit seinen Anrufern. Es bietet die Möglichkeit, Änderungen zu ändern, wenn Sie der Meinung sind, dass mehr Benutzer benötigt werden.

10
Telastyn

Dieser Entwurf folgt dem Parameter Object Pattern . Es löst Probleme, die sich aus vielen Parametern in der Methodensignatur ergeben.

Habe ich Recht, wenn ich denke, dass dies eine Verletzung des Open/Closed-Prinzips ist?

Das Anwenden dieses Musters aktiviert das Öffnungs-/Schließprinzip (OCP). Beispielsweise können abgeleitete Klassen von User als Parameter bereitgestellt werden, die ein anderes Verhalten in der konsumierenden Klasse induzieren.

Werden andere Prinzipien durch diese Praxis gebrochen?

Es kann passieren. Lassen Sie mich anhand der Prinzipien SOLID) erklären.

Das Prinzip der Einzelverantwortung (SRP) kann verletzt werden, wenn es das von Ihnen erläuterte Design aufweist:

Diese Klasse enthält alle Informationen über den Benutzer, seine ID, seinen Namen, die Zugriffsebenen auf jedes Modul, die Zeitzone usw.

Das Problem ist mit alle Informationen. Wenn die Klasse User viele Eigenschaften hat, wird sie zu einem riesigen Datenübertragungsobjekt , das nicht verwandte Informationen aus der Sicht der konsumierenden Klassen transportiert. Beispiel: Aus der Sicht einer konsumierenden Klasse UserAuthentication die Eigenschaft User.Id und User.Name sind relevant, aber nicht User.Timezone.

Das Prinzip der Schnittstellentrennung (ISP) wird ebenfalls mit ähnlichen Überlegungen verletzt, fügt jedoch eine andere Perspektive hinzu. Beispiel: Angenommen, eine konsumierende Klasse UserManagement benötigt die Eigenschaft User.Name aufgeteilt werden in User.LastName und User.FirstName Die Klasse UserAuthentication muss hierfür ebenfalls geändert werden.

Glücklicherweise gibt Ihnen ISP auch einen möglichen Ausweg aus dem Problem: Normalerweise beginnen solche Parameterobjekte oder Datentransportobjekte klein und wachsen mit der Zeit. Wenn dies unhandlich wird, ziehen Sie folgenden Ansatz in Betracht: Führen Sie Schnittstellen ein, die auf die Anforderungen der konsumierenden Klassen zugeschnitten sind. Beispiel: Führen Sie Schnittstellen ein und lassen Sie die Klasse User davon ableiten:

class User : IUserAuthenticationInfo, IUserLocationInfo { ... }

Jede Schnittstelle sollte eine Teilmenge verwandter Eigenschaften der Klasse User verfügbar machen, die eine konsumierende Klasse benötigt, um ihre Operation vollständig auszuführen. Suchen Sie nach Cluster von Eigenschaften. Versuchen Sie, die Schnittstellen wiederzuverwenden. Im Fall der konsumierenden Klasse UserAuthentication verwenden Sie IUserAuthenticationInfo anstelle von User. Teilen Sie dann nach Möglichkeit die Klasse User unter Verwendung der Schnittstellen als "Schablone" in mehrere konkrete Klassen auf.

3
Theo Lenndorff

Lassen Sie uns einfach die einzelnen Aspekte von SOLID überprüfen:

  • Einzelverantwortung: möglicherweise verletzt, wenn die Leute dazu neigen, nur Teile der Klasse herumzugeben.
  • Offen/geschlossen: Nicht relevant, wenn Abschnitte der Klasse herumgereicht werden, nur wenn das vollständige Objekt herumgereicht wird. (Ich denke, hier setzt die kognitive Dissonanz ein: Sie müssen den weit entfernten Code ändern, aber die Klasse selbst scheint in Ordnung zu sein.)
  • Liskov-Substitution: Kein Problem, wir machen keine Unterklassen.
  • Abhängigkeitsinversion (abhängig von Abstraktionen, nicht von konkreten Daten). Ja, das ist verletzt: Die Leute haben keine Abstraktionen, sie nehmen konkrete Elemente der Klasse heraus und geben sie weiter. Ich denke, das ist das Hauptproblem hier.

Eine Sache, die Designinstinkte verwirrt, ist, dass die Klasse im Wesentlichen für globale Objekte und im Wesentlichen schreibgeschützt ist. In einer solchen Situation schadet es nicht sehr, Abstraktionen zu verletzen: Nur das Lesen von Daten, die nicht geändert wurden, führt zu einer ziemlich schwachen Kopplung. Erst wenn es zu einem riesigen Haufen wird, macht sich der Schmerz bemerkbar.
Um Designinstinkte wiederherzustellen, nehmen Sie einfach an, dass das Objekt nicht sehr global ist. Welchen Kontext würde eine Funktion benötigen, wenn das Objekt User jederzeit mutiert werden könnte? Welche Komponenten des Objekts würden wahrscheinlich zusammen mutiert? Diese können aus User aufgeteilt werden, sei es als referenziertes Unterobjekt oder als Schnittstelle, die nur einen "Slice" verwandter Felder verfügbar macht, ist nicht so wichtig.

Ein weiteres Prinzip: Sehen Sie sich die Funktionen an, die Teile von User verwenden, und sehen Sie, welche Felder (Attribute) zusammenpassen. Das ist eine gute vorläufige Liste von Unterobjekten - Sie müssen sich definitiv überlegen, ob sie tatsächlich zusammengehören.

Es ist viel Arbeit und etwas schwierig, und Ihr Code wird etwas weniger flexibel, da es etwas schwieriger wird, das Unterobjekt (Subschnittstelle) zu identifizieren, das an eine Funktion übergeben werden muss, insbesondere wenn sich die Unterobjekte überlappen.

Das Aufteilen von User up wird tatsächlich hässlich, wenn sich die Unterobjekte überlappen. Dann sind die Leute verwirrt darüber, welches ausgewählt werden soll, wenn alle erforderlichen Felder aus der Überlappung stammen. Wenn Sie hierarchisch aufteilen (z. B. haben Sie UserMarketSegment, das unter anderem UserLocation hat), sind sich die Leute nicht sicher, auf welcher Ebene sich die Funktion befindet, die sie schreiben: Handelt es sich um einen Benutzer? Daten auf der Ebene Location oder auf der Ebene MarketSegment? Es hilft nicht gerade, dass sich dies im Laufe der Zeit ändern kann, d. H. Sie können wieder Funktionssignaturen ändern, manchmal über eine gesamte Anrufkette hinweg.

Mit anderen Worten: Wenn Sie Ihre Domain nicht wirklich kennen und keine genaue Vorstellung davon haben, welches Modul sich mit welchen Aspekten von User befasst, lohnt es sich nicht, die Programmstruktur zu verbessern.

2
Toolforger

Als ich in meinem eigenen Code mit diesem Problem konfrontiert wurde, bin ich zu dem Schluss gekommen, dass grundlegende Modellklassen/-objekte die Antwort sind.

Ein häufiges Beispiel wäre das Repository-Muster. Wenn Sie eine Datenbank über Repositorys abfragen, verwenden viele Methoden im Repository häufig dieselben Parameter.

Meine Faustregeln für Repositorys sind:

  • Wenn mehr als eine Methode dieselben 2 oder mehr Parameter verwendet, sollten die Parameter als Modellobjekt zusammengefasst werden.

  • Wenn eine Methode mehr als 2 Parameter akzeptiert, sollten die Parameter als Modellobjekt zusammengefasst werden.

  • Modelle können von einer gemeinsamen Basis erben, aber nur, wenn dies wirklich sinnvoll ist (normalerweise ist es besser, sie später umzugestalten, als mit der Vererbung zu beginnen).


Die Probleme bei der Verwendung von Modellen aus anderen Ebenen/Bereichen werden erst sichtbar, wenn das Projekt etwas komplexer wird. Nur dann, wenn Sie feststellen, dass weniger Code mehr Arbeit oder mehr Komplikationen verursacht.

Und ja, es ist völlig in Ordnung, zwei verschiedene Modelle mit identischen Eigenschaften zu haben, die unterschiedlichen Ebenen/Zwecken dienen (z. B. ViewModels vs POCOs).

2
Dom

Ich finde es am besten, so wenig Parameter wie möglich und so viele wie nötig zu übergeben. Dies erleichtert das Testen und erfordert nicht das Verpacken ganzer Objekte.

Wenn Sie in Ihrem Beispiel nur die Benutzer-ID oder den Benutzernamen verwenden, ist dies alles, was Sie übergeben sollten. Wenn sich dieses Muster mehrmals wiederholt und das tatsächliche Benutzerobjekt viel größer ist, empfehle ich, dafür eine kleinere Schnittstelle zu erstellen. Es könnte sein

interface IIdentifieable
{
    Guid ID { get; }
}

oder

interface INameable
{
    string Name { get; }
}

Dies erleichtert das Testen mit Verspotten erheblich und Sie wissen sofort, welche Werte wirklich verwendet werden. Andernfalls müssen Sie häufig komplexe Objekte mit vielen anderen Abhängigkeiten initialisieren, obwohl Sie am Ende nur eine oder zwei Eigenschaften benötigen.

1
t3chb0t

Folgendes ist mir von Zeit zu Zeit begegnet:

  • Eine Methode verwendet ein Argument vom Typ User (oder Product oder was auch immer), das viele Eigenschaften hat, obwohl die Methode nur einige davon verwendet.
  • Aus irgendeinem Grund muss ein Teil des Codes diese Methode aufrufen, obwohl sie kein vollständig ausgefülltes User -Objekt enthält. Es erstellt eine Instanz und initialisiert nur die Eigenschaften, die die Methode tatsächlich benötigt.
  • Dies passiert einige Male.
  • Wenn Sie nach einer Weile auf eine Methode mit dem Argument User stoßen, müssen Sie Aufrufe für diese Methode finden, um herauszufinden, woher das User stammt, damit Sie wissen, welche Eigenschaften ausgefüllt sind . Ist es ein "echter" Benutzer mit einer E-Mail-Adresse oder wurde es nur erstellt, um eine Benutzer-ID und einige Berechtigungen zu übergeben?

Wenn Sie ein User erstellen und nur einige wenige Eigenschaften ausfüllen, da diese von der Methode benötigt werden, weiß der Aufrufer wirklich mehr über das Innenleben der Methode als er sollte.

Schlimmer noch, wenn Sie haben eine Instanz von User, müssen Sie wissen, woher sie stammt, damit Sie wissen, welche Eigenschaften ausgefüllt sind. Das willst du nicht wissen müssen.

Wenn Entwickler sehen, dass User als Container für Methodenargumente verwendet wird, können sie im Laufe der Zeit Eigenschaften für Einweg-Szenarien hinzufügen. Jetzt wird es hässlich, weil die Klasse mit Eigenschaften überfüllt ist, die fast immer null oder standardmäßig sind.

Eine solche Beschädigung ist nicht unvermeidlich, aber sie tritt immer wieder auf, wenn wir ein Objekt weitergeben, nur weil wir Zugriff auf einige seiner Eigenschaften benötigen. Die Gefahrenzone ist das erste Mal, dass jemand eine Instanz von User erstellt und nur einige Eigenschaften auffüllt, damit er sie an eine Methode übergeben kann. Setzen Sie Ihren Fuß darauf, weil es ein dunkler Pfad ist.

Geben Sie nach Möglichkeit das richtige Beispiel für den nächsten Entwickler, indem Sie nur das übergeben, was Sie übergeben müssen.

1
Scott Hannen

Das ist eine wirklich interessante Frage. Es kommt darauf an.

Wenn Sie der Meinung sind, dass sich Ihre Methode in Zukunft intern ändern könnte, um andere Parameter des Benutzerobjekts zu erfordern, sollten Sie das Ganze auf jeden Fall übergeben. Der Vorteil ist, dass der Code außerhalb der Methode dann vor Änderungen innerhalb der Methode in Bezug auf die verwendeten Parameter geschützt ist, was, wie Sie sagen, eine Kaskade von Änderungen extern verursachen würde. Das Übergeben des gesamten Benutzers erhöht also die Kapselung.

Wenn Sie sich ziemlich sicher sind, dass Sie niemals etwas anderes als die E-Mail-Adresse des Benutzers verwenden müssen, sollten Sie dies weitergeben. Der Vorteil davon ist, dass Sie die Methode dann in einem breiteren Spektrum von Kontexten verwenden können: Sie können sie beispielsweise verwenden mit der E-Mail eines Unternehmens oder mit einer E-Mail, die gerade jemand eingegeben hat. Dies erhöht die Flexibilität.

Dies ist Teil einer breiteren Palette von Fragen zum Erstellen von Klassen mit einem breiten oder engen Umfang, einschließlich der Frage, ob Abhängigkeiten eingefügt werden sollen oder nicht und ob global verfügbare Objekte vorhanden sein sollen. Im Moment gibt es eine unglückliche Tendenz zu glauben, dass ein engerer Spielraum immer gut ist. Wie in diesem Fall gibt es jedoch immer einen Kompromiss zwischen Kapselung und Flexibilität.

1