Geben Sie Ihren Code schließlich ein

Hallo Habr!


Neulich habe ich wieder den Typcode bekommen


if(someParameter.Volatilities.IsEmpty()) { // We have to report about the broken channels, however we could not differ it from just not started cold system. // Therefore write this case into the logs and then in case of emergency IT Ops will able to gather the target line Log.Info("Channel {0} is broken or was not started yet", someParameter.Key) } 

Der Code enthält eine wichtige Funktion: Der Empfänger möchte sehr gerne wissen, was wirklich passiert ist. In einem Fall haben wir Probleme mit dem System, in dem anderen wärmen wir uns einfach auf. Das Modell gibt uns dies jedoch nicht (um dem Absender zu gefallen, der häufig der Autor des Modells ist).
Darüber hinaus ist selbst die Tatsache, dass möglicherweise etwas nicht stimmt, darauf zurückzuführen, dass die Volatilities Sammlung leer ist. Was in einigen Fällen richtig sein kann.


Ich bin sicher, dass die meisten erfahrenen Entwickler im Code Zeilen gesehen haben, die geheimes Wissen im Stil von "Wenn diese Kombination von Flags gesetzt ist, werden wir gebeten, A, B und C zu erstellen" (obwohl dies für das Modell selbst nicht sichtbar ist).


Aus meiner Sicht wirken sich solche Einsparungen bei der Struktur von Klassen in Zukunft äußerst negativ auf das Projekt aus und verwandeln es in eine Reihe von Hacks und Krücken, wodurch ein mehr oder weniger praktischer Code schrittweise in Legacy umgewandelt wird.


Wichtig: In dem Artikel gebe ich Beispiele, die für Projekte nützlich sind, in denen mehrere Entwickler (und nicht einer) arbeiten, und die mindestens 5-10 Jahre lang aktualisiert und erweitert werden. All dies ist nicht sinnvoll, wenn das Projekt fünf Jahre lang einen Entwickler hat oder wenn nach der Veröffentlichung keine Änderungen geplant sind. Und es ist logisch, wenn das Projekt nur für ein paar Monate benötigt wird, macht es keinen Sinn, in ein klares Datenmodell zu investieren.


Wenn Sie jedoch lange spielen - willkommen bei cat.


Verwenden Sie das Besuchermuster


Oft enthält dasselbe Feld ein Objekt, das unterschiedliche semantische Bedeutungen haben kann (wie im Beispiel). Um Klassen zu speichern, lässt der Entwickler jedoch nur einen Typ übrig und versorgt ihn mit Flags (oder Kommentaren im Stil "Wenn hier nichts ist, wurde nichts gezählt"). Ein ähnlicher Ansatz kann einen Fehler maskieren (was für das Projekt schlecht, aber für das Team, das den Service bereitstellt, praktisch ist, da die Fehler von außen nicht sichtbar sind). Eine korrektere Option, mit der Sie sogar am anderen Ende des Kabels herausfinden können, was tatsächlich passiert, ist die Verwendung der Schnittstelle + Besucher.


In diesem Fall wird das Beispiel aus der Kopfzeile zum Code des Formulars:


 class Response { public IVolatilityResponse Data { get; } } interface IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) } class VolatilityValues : IVolatilityResponse { public Surface Data; TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } class CalculationIsBroken : IVolatilityResponse { TOutput Visit<TInput, TOutput>(IVolatilityResponseVisitor<TInput, TOutput> visitor, TInput input) => visitor.Visit(this, input); } interface IVolatilityResponseVisitor<TInput, TOutput> { TOutput Visit(VolatilityValues instance, TInput input); TOutput Visit(CalculationIsBroken instance, TInput input); } 

Mit dieser Art der Verarbeitung:


  • Wir brauchen mehr Code. Wenn wir mehr Informationen im Modell ausdrücken möchten, sollten es leider mehr sein.
  • Aufgrund dieser Art der Vererbung können wir die Response auf json / protobuf nicht mehr serialisieren, da dort protobuf verloren gehen. Wir müssen einen speziellen Container erstellen, der dies erledigt (Sie können beispielsweise eine Klasse erstellen, die für jede Implementierung ein separates Feld enthält, von dem jedoch nur eines ausgefüllt wird).
  • Um das Modell zu erweitern ( IVolatilityResponseVisitor<TInput, TOutput> neue Klassen hinzuzufügen), muss die Schnittstelle IVolatilityResponseVisitor<TInput, TOutput> erweitert werden. IVolatilityResponseVisitor<TInput, TOutput> bedeutet, dass der Compiler die Unterstützung im Code IVolatilityResponseVisitor<TInput, TOutput> . Der Programmierer wird nicht vergessen, den neuen Typ zu verarbeiten, da das Projekt sonst nicht kompiliert wird.
  • Aufgrund der statischen Typisierung müssen wir keine Dokumentation mit möglichen Kombinationen von Feldern usw. irgendwo speichern. Wir haben alle möglichen Optionen im Code beschrieben, die sowohl für den Compiler als auch für die Person verständlich sind. Wir werden keine Desynchronisation zwischen Dokumentation und Code haben, da wir auf die erste verzichten können.

Über die Einschränkung der Vererbung in anderen Sprachen


Eine Reihe anderer Sprachen (z. B. Scala oder Kotlin ) verfügen über Schlüsselwörter, mit denen Sie unter bestimmten Bedingungen das Erben von einem bestimmten Typ verbieten können. So kennen wir in der Kompilierungsphase alle möglichen Nachkommen unseres Typs.


Insbesondere kann das obige Beispiel in Kotlin folgendermaßen umgeschrieben werden:


 class Response ( val data: IVolatilityResponse ) sealed class VolatilityResponse class VolatilityValues : VolatilityResponse() { val data: Surface } class CalculationIsBroken : VolatilityResponse() 

Es stellte sich heraus, dass es etwas weniger als der Code war, aber jetzt beim Kompilieren wissen wir, dass sich alle möglichen VolatilityResponse in derselben Datei befinden, was bedeutet, dass der folgende Code nicht kompiliert wird, da wir nicht alle möglichen Werte der Klasse durchlaufen haben.


 fun getResponseString(response: VolatilityResponse) = when(response) { is VolatilityValues -> data.toString() } 

Es ist jedoch zu beachten, dass solche Überprüfungen nur für Funktionsaufrufe funktionieren. Der folgende Code wird fehlerfrei kompiliert:


 fun getResponseString(response: VolatilityResponse) { when(response) { is VolatilityValues -> println(data.toString()) } } 

Nicht alle primitiven Typen bedeuten dasselbe


Betrachten Sie eine relativ typische Entwicklung für eine Datenbank. Höchstwahrscheinlich haben Sie irgendwo im Code Objektkennungen. Zum Beispiel:


 class Group { public int Id { get; } public string Name { get; } } class User { public int Id { get; } public int GroupId { get; } public string Name { get; } } 

Es scheint wie ein Standardcode. Die Typen stimmen sogar mit denen in der Datenbank überein. Die Frage ist jedoch: Ist der folgende Code korrekt?


 public bool IsInGroup(User user, Group group) { return user.Id == group.Id; } public User CreateUser(string name, Group group) { return new User { Id = group.Id, GroupId = group.Id, name = name } } 

Die Antwort ist höchstwahrscheinlich nicht, da wir im ersten Beispiel die Benutzer- Id und die Gruppen- Id . Und im zweiten Fall haben wir fälschlicherweise die id von Group als die id von User .


Seltsamerweise ist dies recht einfach zu beheben: GroupId UserId einfach die Typen GroupId , UserId und so weiter. Daher funktioniert die Erstellung des User nicht mehr, da Ihre Typen nicht konvergieren. Das ist unglaublich cool, weil man dem Compiler vom Modell erzählen kann.


Darüber hinaus funktionieren Methoden mit denselben Parametern für Sie korrekt, da sie jetzt nicht wiederholt werden:


 public void SetUserGroup(UserId userId, GroupId groupId) { /* some sql code */ } 

Kehren wir jedoch zum Beispiel des Vergleichs von Bezeichnern zurück. Dies ist etwas komplizierter, da Sie verhindern müssen, dass der Compiler das Unvergleichliche während des Erstellungsprozesses vergleicht.


Und Sie können dies wie folgt tun:


 class GroupId { public int Id { get; } public bool Equals(GroupId groupId) => Id == groupId?.Id; [Obsolete("GroupId can be equal only with GroupId", error: true)] public override bool Equals(object obj) => Equals(obj as GroupId) public static bool operator==(GroupId id1, GroupId id2) { if(ReferenceEquals(id1, id2)) return true; if(ReferenceEquals(id1, null) || ReferenceEquals(id2, null)) return false; return id1.Id == id2.Id; } [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(object _, GroupId __) => throw new NotSupportedException("GroupId can be equal only with GroupId") [Obsolete("GroupId can be equal only with GroupId", error: true)] public static bool operator==(GroupId _, object __) => throw new NotSupportedException("GroupId can be equal only with GroupId") } 

Als Ergebnis:


  • Wir brauchten wieder mehr Code. Wenn Sie dem Compiler mehr Informationen geben möchten, müssen Sie häufig mehr Zeilen schreiben.
  • Wir haben neue Typen erstellt (wir werden im Folgenden auf Optimierungen eingehen), die manchmal die Leistung geringfügig beeinträchtigen können.
  • In unserem Code:
    • Wir haben verboten, Identifikatoren zu verwechseln. Sowohl der Compiler als auch der Entwickler sehen jetzt deutlich, dass es unmöglich ist GroupId Feld GroupId in das Feld GroupId zu GroupId
    • Es ist uns verboten, das Unvergleichliche zu vergleichen. Ich werde IEquitable dass der Vergleichscode nicht vollständig ist (es ist auch wünschenswert, die IEquitable Schnittstelle zu implementieren, Sie müssen auch die GetHashCode Methode implementieren), sodass das Beispiel nicht nur in das Projekt kopiert werden muss. Die Idee selbst ist jedoch klar: Wir haben dem Compiler ausdrücklich untersagt, auszudrücken, wann die falschen Typen verglichen wurden. Das heißt, anstatt zu sagen "Sind diese Früchte gleich?" Der Compiler sieht nun "Ist eine Birne gleich einem Apfel?".

Ein bisschen mehr über SQL und Einschränkungen


In unseren Anwendungen für Typen werden häufig zusätzliche Regeln eingeführt, die leicht zu überprüfen sind. Im schlimmsten Fall sehen einige Funktionen ungefähr so ​​aus:


 void SetName(string name) { if(name == null || name.IsEmpty() || !name[0].IsLetter || !name[0].IsCapital || name.Length > MAX_NAME_COLUMN_LENGTH) { throw .... } /**/ } 

Das heißt, die Funktion nimmt eine ziemlich breite Art von Eingabe entgegen und führt dann die Überprüfungen aus. Dies ist im Allgemeinen nicht der Fall, da:


  • Wir haben dem Programmierer und Compiler nicht erklärt, was wir hier wollen.
  • In einer anderen ähnlichen Funktion müssen Sie die Schecks kopieren.
  • Als wir eine string , die den name kennzeichnet, fielen wir nicht sofort, sondern aus irgendeinem Grund wurde die weitere Ausführung auf einige Prozessoranweisungen später beschränkt.

Das richtige Verhalten:


  • Erstellen Sie einen separaten Typ (in unserem Fall anscheinend Name ).
  • Führen Sie darin alle erforderlichen Validierungen und Überprüfungen durch.
  • Wickeln Sie die string so schnell wie möglich in Name so schnell wie möglich einen Fehler zu erhalten.

Als Ergebnis erhalten wir:


  • Weniger Code, da wir die Namensprüfungen im Konstruktor ausgecheckt haben.
  • Fail Fast- Strategie - Nachdem wir einen problematischen Namen erhalten haben, werden wir sofort fallen, anstatt ein paar weitere Methoden aufzurufen, aber immer noch fallen. Anstelle eines Fehlers aus einer Datenbank vom Typ Typ zu groß stellen wir außerdem sofort fest, dass es keinen Sinn macht, solche Namen überhaupt zu verarbeiten.
  • Es ist für uns bereits schwieriger, die Argumente zu verwechseln, wenn die Funktionssignatur lautet: void UpdateData(Name name, Email email, PhoneNumber number) . Schließlich übergeben wir jetzt nicht drei identische string , sondern drei verschiedene unterschiedliche Entitäten.

Ein bisschen über Casting


Wenn wir eine ziemlich strenge Typisierung einführen, sollten wir auch nicht vergessen, dass wir beim Übertragen von Daten nach SQL immer noch eine echte Kennung benötigen. In diesem Fall ist es logisch, die Typen, die eine string umschließen, geringfügig zu aktualisieren:


  • Hinzufügen einer Implementierung einer Schnittstelle der Formularschnittstelle interface IValueGet<TValue>{ TValue Wrapped { get; } } interface IValueGet<TValue>{ TValue Wrapped { get; } } . In diesem Fall können wir in der Übersetzungsschicht in SQL den Wert direkt abrufen
  • Anstatt eine Reihe von mehr oder weniger identischen Typen im Code zu erstellen, können Sie einen abstrakten Vorfahren erstellen und den Rest davon erben. Das Ergebnis ist ein Code der Form:

 interface IValueGet<TValue> { TValue Wrapped { get; } } abstract class BaseWrapper : IValueGet<TValue> { protected BaseWrapper(TValue initialValue) { Wrapped = initialValue; } public TValue Wrapped { get; private set; } } sealed class Name : BaseWrapper<string> { public Name(string value) :base(value) { /*no necessary validations*/ } } sealed class UserId : BaseWrapper<int> { public UserId(int id) :base(id) { /*no necessary validations*/ } } 

Leistung


Wenn Sie über das Erstellen einer großen Anzahl von Typen sprechen, können Sie häufig zwei dialektische Argumente treffen:


  • Je mehr Typen, Verschachtelung und il-Code vorhanden sind, desto langsamer ist die Software, da es für jit schwieriger ist, das Programm zu optimieren. Daher führt diese Art der strengen Eingabe zu ernsthaften Bremsen im Projekt.
  • Je mehr Wrapper, desto mehr Speicherplatz verbraucht die Anwendung. Das Hinzufügen von Wrappern erhöht daher die RAM-Anforderungen erheblich.

Genau genommen werden beide Argumente jedoch oft ohne Fakten vorgebracht:


  • Tatsächlich belegen in den meisten Anwendungen auf demselben Java Zeichenfolgen (und Bytearrays) den Hauptspeicher. Das heißt, das Erstellen von Wrappern ist für den Endbenutzer im Allgemeinen unwahrscheinlich. Aufgrund dieser Art der Eingabe erhalten wir jedoch ein wichtiges Plus: Bei der Analyse eines Speicherauszugs können Sie bewerten, welchen Beitrag jeder Ihrer Typen zum Speicher leistet. Schließlich sehen Sie nicht nur eine anonyme Liste von Zeilen, die über das Projekt verteilt sind. Im Gegenteil, wir können verstehen, welche Arten von Objekten größer sind. Aufgrund der Tatsache, dass nur Wrapper Zeichenfolgen und andere massive Objekte enthalten, ist es für Sie einfacher zu verstehen, welchen Beitrag jeder einzelne Wrapper-Typ zum gemeinsam genutzten Speicher leistet.
  • Das Argument der JIT-Optimierung ist teilweise richtig, aber nicht vollständig. In der Tat beginnt Ihre Software aufgrund der strengen Eingabe, zahlreiche Überprüfungen am Eingang der Funktionen zu beseitigen. Alle Ihre Modelle werden auf ihre Eignung überprüft. Im allgemeinen Fall haben Sie also weniger Überprüfungen (es reicht aus, einfach den richtigen Typ zu benötigen). Aufgrund der Tatsache, dass Überprüfungen an den Konstruktor übertragen und nicht durch Code verschmiert werden, ist es außerdem einfacher zu bestimmen, welche von ihnen wirklich Zeit benötigen.
  • Leider kann ich in diesem Artikel keinen vollständigen Leistungstest geben, der das Projekt mit einer großen Anzahl von Mikrotypen und mit der klassischen Entwicklung vergleicht und nur int , string und andere primitive Typen verwendet. Der Hauptgrund ist, dass Sie dafür zuerst ein typisches fett gedrucktes Projekt für den Test erstellen und dann begründen müssen, dass dieses bestimmte Projekt ein typisches ist. Und mit dem zweiten Punkt ist alles kompliziert, da die Projekte im wirklichen Leben wirklich anders sind. Es wird jedoch ziemlich seltsam sein, synthetische Tests durchzuführen, da, wie ich bereits sagte, die Erstellung von Mikrotypobjekten in Unternehmensanwendungen nach meinen Messungen immer vernachlässigbare Ressourcen hinterließ (auf der Ebene des Messfehlers).

Wie können Sie einen Code optimieren, der aus einer großen Anzahl solcher Mikrotypen besteht?


Wichtig: Sie sollten sich mit solchen Optimierungen nur befassen, wenn Sie garantierte Fakten erhalten, dass es Mikrotypen sind, die die Anwendung verlangsamen. Nach meiner Erfahrung ist eine solche Situation eher unmöglich. Mit einer höheren Wahrscheinlichkeit verlangsamt Sie derselbe Logger , da jeder Vorgang auf ein Flush auf die Festplatte wartet (auf dem Computer des Entwicklers mit M.2-SSD war alles akzeptabel, aber ein Benutzer mit einer alten Festplatte sieht völlig andere Ergebnisse).


Die Tricks selbst:


  • Verwenden Sie aussagekräftige Typen anstelle von Referenztypen. Dies kann nützlich sein, wenn Wrapper auch mit signifikanten Typen arbeitet, was bedeutet, dass Sie theoretisch alle erforderlichen Informationen durch den Stapel leiten können. Es sollte jedoch beachtet werden, dass die Beschleunigung nur dann erfolgt, wenn Ihr Code gerade aufgrund von Mikrotypen wirklich unter häufigem GC leidet.
    • struct in .Net kann häufiges Boxen / Unboxing verursachen. Gleichzeitig benötigen solche Strukturen möglicherweise mehr Speicher in Dictionary / Map Sammlungen (da Arrays mit einem Rand versehen sind).
    • inline Typen von Kotlin / Scala sind nur begrenzt anwendbar. Beispielsweise können Sie nicht mehrere Felder darin speichern (was manchmal nützlich sein kann, um den ToString / GetHashCode Wert zwischenzuspeichern).
    • Eine Reihe von Optimierern kann Speicher auf dem Stapel zuweisen. Insbesondere .Net tut dies für kleine temporäre Objekte , und GraalVM in Java kann ein Objekt auf dem Stapel zuweisen, es dann aber auf den Heap kopieren, wenn es zurückgegeben werden musste (geeignet für Code, der reich an Bedingungen ist).
  • Verwenden Sie die Internierung von Objekten (dh versuchen Sie, vorgefertigte, vorgefertigte Objekte zu nehmen).
    • Wenn der Konstruktor ein Argument hat, können Sie einfach einen Cache erstellen, in dem der Schlüssel dieses Argument ist und der Wert das zuvor erstellte Objekt ist. Wenn die Vielfalt der Objekte recht gering ist, können Sie die vorgefertigten Objekte einfach wiederverwenden.
    • Wenn ein Objekt mehrere Argumente hat, können Sie einfach ein neues Objekt erstellen und dann überprüfen, ob es sich im Cache befindet. Wenn es eine ähnliche gibt, ist es besser, die bereits erstellte zurückzugeben.
    • Ein solches Schema verlangsamt die Arbeit der Designer, da Equals / GetHashCode für alle Argumente GetHashCode muss. Es beschleunigt jedoch auch zukünftige Vergleiche von Objekten, wenn Sie den Wert des Hashs zwischenspeichern, da in diesem Fall die Objekte unterschiedlich sind, wenn sie unterschiedlich sind. Und identische Objekte haben oft eine Verknüpfung.
    • Diese Optimierung beschleunigt jedoch das Programm aufgrund des schnelleren GetHashCode / Equals (siehe Abschnitt oben). Außerdem sinkt die Lebensdauer neuer Objekte (die sich jedoch im Cache befinden) dramatisch, sodass sie nur in die Generation 0 gelangen.
  • Überprüfen Sie beim Erstellen neuer Objekte die Eingabeparameter und passen Sie sie nicht an. Trotz der Tatsache, dass dieser Rat häufig im Abschnitt über den Codierungsstil enthalten ist, können Sie damit die Effektivität des Programms steigern. Wenn für Ihr Objekt beispielsweise eine Zeichenfolge mit nur GROSSBUCHSTABEN erforderlich ist, werden häufig zwei Ansätze verwendet, um zu überprüfen: entweder ToUpperInvariant aus dem Argument ToUpperInvariant oder in einer Schleife überprüfen, ob alle Buchstaben groß sind. Im ersten Fall wird garantiert eine neue Zeile erstellt, im zweiten Fall wird ein maximaler Iterator erstellt. Infolgedessen sparen Sie Speicherplatz (in beiden Fällen wird jedoch jedes Zeichen weiterhin überprüft, sodass die Leistung nur im Kontext einer selteneren Speicherbereinigung erhöht wird).

Fazit


Ich werde noch einmal den wichtigen Punkt aus dem Titel wiederholen: Alle im Artikel beschriebenen Dinge sind in großen Projekten sinnvoll, die seit Jahren entwickelt und verwendet werden. In Fällen, in denen es sinnvoll ist, die Supportkosten und die Kosten für das Hinzufügen neuer Funktionen zu senken. In anderen Fällen ist es oft am sinnvollsten, ein Produkt so schnell wie möglich herzustellen, ohne sich um Tests, Modelle und „guten Code“ zu kümmern.


Für langfristige Projekte ist es jedoch sinnvoll, die strengste Typisierung zu verwenden, wobei wir im Modell genau beschreiben können, welche Werte im Prinzip möglich sind.


Wenn Ihr Service manchmal ein nicht funktionierendes Ergebnis zurückgeben kann, drücken Sie es im Modell aus und zeigen Sie es dem Entwickler explizit. Fügen Sie nicht tausend Flags mit Beschreibungen in der Dokumentation hinzu.


Wenn Ihre Typen im Programm identisch sein können, sie sich jedoch im Wesentlichen im Geschäft unterscheiden, definieren Sie sie genau als unterschiedlich. Mischen Sie sie nicht, auch wenn die Arten ihrer Felder gleich sind.


Wenn Sie Fragen zur Produktivität haben, wenden Sie die wissenschaftliche Methode an und machen Sie einen Test (oder bitten Sie eine unabhängige Person, dies alles zu überprüfen). In diesem Szenario beschleunigen Sie das Programm und verschwenden nicht nur die Zeit des Teams. Das Gegenteil ist jedoch auch der Fall: Wenn der Verdacht besteht, dass Ihr Programm oder Ihre Bibliothek langsam ist, führen Sie einen Test durch. Sie müssen nicht sagen, dass alles in Ordnung ist. Zeigen Sie es einfach in Zahlen.

Source: https://habr.com/ru/post/de462655/


All Articles